memo: Wayland compositor と redraw と VBlank

|

Anvil vs niri

smithay crate は Rust で Wayland を扱える素晴らしい crate です.

ですが付属の Wayland compositor 実装である Anvil が (niri と比較すると) かなり読みにくいという問題があります. その一因として backend (udev, winit) が抽象的に分離されていないというのがある気がします. このへんを整理していたのですが udev backend と winit backend で redraw の処理がかなり違うのがネックになってきました.

というわけで調査と比較するね. はいどうぞ!

niri

https://github.com/YaLTeR/niri/tree/f203c8729a8535f6a317df5a35dc01306be2e45c

https://github.com/YaLTeR/niri/blob/f203c8729a8535f6a317df5a35dc01306be2e45c/src/main.rs#L261-L263

niri は calloop::EventLoop::run() でループを駆動している. 中の dispatch() で Wayland client からのイベントなども処理している. niri::State::refresh_and_flush_clients() から niri::Backend::render() が呼び出される.

tty backend の場合は DrmCompositor::render_frame()DrmCompositor::queue_frame() が呼び出される. しかしこのループを愚直に回すと以下の問題が出てしまう.

  1. ディスプレイの FPS を上回る頻度で render してしまう.
  2. rerender の必要がないのに render してしまう. (表示しているどのウインドウも更新がない場合など.)

1.は VBlank を使って解決する. redraw は niri::RedrawState を使って管理されている. frame が scan-out されたら DrmEvent::VBlank が通知されるのでここで redraw state を Idle にする. redraw state が Idle のものは skip される ので無駄な render が抑えられるというワケ.

2.は wl_surface::commit などを使って解決する. CompositorHandler::commit(), つまり wl_surface::commit が呼ばれるときに niri::Niri::queue_redraw() する.

他にも queue_redraw() を呼んでいるところはたくさんある. 例えばウインドウを掴んで移動しているときなどはウインドウの内容自体は変わっているとは限らないので queue_redraw() する必要がある.

この様に, redraw を抑制し, 必要があるときだけ発火させるという感じでやっている. 上手いね.

因みにこの挙動は terminal を開いて for i in $(seq 0 10000); do echo $i; sleep 1; done とかやるとわかりやすい.

注意としては, niri はアニメーションもあるので アニメーション中は redraw を続ける といった処理も必要になるあたりか.

winit backend も基本は同じだが, (僕の理解では) VBlank 通知に相当する frame callback を待たずに winit::window::Window::request_redraw() している.

Anvil

https://github.com/Smithay/smithay/tree/8e49b9bb1849f0ead1ba2c7cd76802fc12ad6ac3

udev/winit backend はそれぞれ異なるループを持つ.

まずは udev backend の方.

main のループ を持つこと自体は同じ. これは簡単に calloop::EventLoop::run() に置き換え可能である. さて, この main ループ自体は rendering に絡まない. 実はもうひとつ rendering 用のループが存在する.

この oneshot timer のどちらかを何らかのタイミングでやめると描画されなくなる. (挙動がめちゃくちゃ追いにくい. もうちょっとなんとかならんかったんか?)

このループは概ね 1/FPS 秒ごとに1周する. scan-out されるパスは VBlank に入るが, scan-out されないケースでも (例えば DrmCompositor を使っている場合) DrmCompositor::render_frame() が呼び出される. (詳しくないのだけれど, 消費電力とか大丈夫なんですかね...?)

このループが切り離されているから main ループが簡素というワケ.

winit backend の場合 main ループの中で render している. この違いが backend を state から分離するのを難しくしている.

Mutter (GTK)

https://gitlab.gnome.org/GNOME/mutter

読もうとしたけどまったくわからん. C 言語と GObject 難しすぎひん?

KWin (KDE)

https://github.com/KDE/kwin/tree/v6.2.2

ここから雑度が上がる.

main ループはここ で, それとは別に WaylandOutput が RenderLoop を保持している. RenderLoopふたつ timer を持っている. ふたつめの timer のことは忘れよう. (他に RenderLoop::scheduleRepaint() を呼び出しているの, DrmOutput だけだし.)

これでループする.

上では scan-out されたケースを書いたが, そうでないケースは OutputFrame::~OutputFrame() から RenderLoopPrivate::scheduleNextRepaint() される.

つまり Anvil とだいたい一緒.

まとめ

Anvil ベースのやつを改良しようと思ったけど KWin と似た方法だしこれが特別変なことをしているわけではないっぽいことがわかってしまった. どうしよう... (まぁ構造をキープしつつマシにするのはどうとでもできる気はするが.)