Open5

RustのeguiでGUI

tristris

(Zennを始めてみたので、慣れるためと、自分用のメモ的な)

概要

RustのGUIクレートegui。読み方は「エグイ」? 「イーグイ」?

Pure Rustで、ネイティブ環境でも動くし、ブラウザ上でのWASMでも動く。

曰く、「eguiはフレームワークではなくライブラリ」らしい。プログラムするための環境ではないからだとか。ちょっと何言ってるか分かりませんね。

リポジトリ
eguiのドキュメント
epiのドキュメント

WASMで動くサンプル。触ったらわかると思うけど、癖がすごい。Webっぽい操作感だと思えば、許容範囲には収まっている、かな。

そこそこ実用的なGUIクレートだと思うけど、普通のGUIの文脈ではなく、なぜかゲーム系の方でしか見かけない。This Month in Rust GameDevでよく取り上げられている。バックボーンがゲームなのかも?(調べてない)

使い方のドキュメントが少なすぎて超困る。日本語は皆無、英語ですらほとんど見付からないってどういうことなの。

ウィジェットが豊富。ボタンやラベルをはじめとして、普通に使えるレベルのものは揃っている。今のところテーブルはないっぽいけど、「最もパワフルなGUIを目指しているわけではない」と明言されていることもあり、仕方ないのではないかと。

コンパイル時間が短いのが良い。体感、icedの1/10くらいの時間でビルドが終わる感じ。まだeguiでは小規模なものだけしか作っていないからだけなのかも知れないけど。

即時モード(immediate mode。訳合ってる?)に対するこだわりがすごい。リポジトリの
README.mdの下の方で、なぜ即時モードを採用しているかについて、結構な文章量で書かれている。それなのに中身としてはデメリットについて記している部分の方が圧倒的に長くて、いまいち意図が読み取れないのだけれど。

tristris

即時モードについてリポジトリのトップページの下の方で熱く語られているので、適当に訳してみる。

なんで即時モードなの?

eguiは保持モード(retained mode)のGUIライブラリじゃなくて、即時モード(immediate mode)のGUIライブラリだよ。保持モードと即時モードの違いは、ボタンの例で説明するのが一番分かりやすいかな。
保持モードのGUIでは、ボタンを作って、それをUIに追加して、さらにそれがクリックされたときのハンドラ(コールバック)を設定するよね。ボタンはUIの中に保持されていて、ボタンの文字を変更するならボタンへの何らかの参照を保持しておく必要があるわけ。
これに対して即時モードでは、ボタンの表示と、操作による変化を、毎フレーム(毎秒60回とかの高頻度で)即時に処理してるんだよ。つまり、on-clickハンドラや、ボタンへの参照を持たなくていい。だからeguiではこんな感じになるよ。if ui.button("Save file").clicked() { save(file); }

どっちのやり方にもメリットとデメリットがあるよ。

簡単に言えば、即時モードのGUIライブラリは使いやすいけど、機能的には劣っちゃうんだよね。

即時モードの利点

操作性

即時モードの最大のメリットは、アプリケーションのコードがとってもシンプルになることだよ。

  • コードの流れを乱すようなオンクリックハンドラやコールバックはいらないよ。
  • コールバックが、消えてしまったものを呼び出さないか心配しなくていいよ。
  • GUIのコードをシンプルな関数の中に入れられるよ。(UIのためだけのオブジェクトはいらないよ)
  • アプリの状態とGUIの状態が同期していなくて、GUIの表示が古いようなことを心配しなくていいよ。だって、GUIは状態を保存してるんじゃなくて、最新の状態を即時に表示しているからだよ。

言い換えれば、大量のコード・複雑さ・バグがなくなって、GUIのコードを書くよりも面白いことに時間を使えるようになるってことだね。

即時モードの欠点

レイアウト

即時モードの最大の欠点は、レイアウトが難しいことなんだよね。例えば、小さなダイアログウィンドウを画面の中央に表示したいとするよね。ウィンドウを正しく配置するためには、GUIライブラリはウィンドウのサイズを知らなくちゃダメ。さらにウィンドウのサイズを知るためには、GUIライブラリはまずウィンドウの内容をレイアウトする必要がある。
保持モードではこれは簡単で、GUIライブラリがウィンドウのレイアウトを行ってウィンドウを配置し、操作(例えばOKボタンがクリックされたか、とか)なんかをチェックするよ。

これ、即時モードだとパラドックスになっちゃうんだよ。ウィンドウのサイズを知るためにはレイアウトを行わなくちゃならないけど、レイアウトのコードは「OKボタンがクリックされたか」みたいなこともチェックするから、ウィンドウの内容を表示する前にウィンドウの位置が分かってないとダメなんだよね。つまり、ウィンドウのサイズを知る前に、どこにウィンドウを表示するかを決めなくちゃならないんだよ。

これが即時モードのGUIの基本的な欠点で、解決しようとするといろいろ大変なんだよね。

それを回避する方法として、サイズを保存して次のフレームで使用するっていう方法がある。でもこれだと、正しいレイアウトのためにフレームの遅延が発生して、何かが表示される最初のフレームでたまにチラついちゃうんだよね。eguiでは、ウィンドウやグリッドレイアウトなんかの一部ではこの方法を使ってるよ。

レイアウトコードを2回呼び出す方法もある。1回はサイズ取得で、1回は操作に対する反応、って感じで。でもこれコストが高いし、実装が複雑になるし、場合によっては2回じゃ足りないんだよね。ってことで、eguiではこの方法は使ってないよ。

「アトミック」なウィジェット(ボタンとかラベルとか)については、eguiは表示する前にサイズを知ってるから、中央揃えみたいなことは深く考えずに実施できるよ。

CPU使用率

即時モードのGUIではフレーム(画面更新)ごとに全部のレイアウトを行うから、レイアウトするコードは高速じゃないとダメだよ。とても複雑なGUIを使う場合はCPUに負担がかかっちゃう。特に、非常に大きなUIをスクロールエリアに配置してすごく長いスクロールになった場合、コンテンツを毎回レイアウトするから遅くなっちゃうよ。

この点を考慮してGUIを設計して、巨大なスクロールエリアを作らなければ、パフォーマンスへの影響はほとんどないよ。大抵の場合、eguiは1フレームあたり1~2msかかる感じ。でもeguiにはまだまだ最適化の余地があるよ(まだ作者さんがそれに取り組んでるわけじゃないけど)。
それに、マウスの動きみたいなインタラクションがあったときだけ再描画するような設定にもできるよ。

GUIがとってもインタラクティブなものだったら、保持モードより即時モードの方が実際にはパフォーマンスが高いかもね。どんなWebページでも、ブラウザのウィンドウサイズを変更すると、ブラウザがレイアウトを行うのにとっても時間がかかって、CPUをすごく使ってるでしょ? でもeguiでウィンドウをリサイズしても、CPUをそんなに使わず、60FPSを実現できるよ。

ID

eguiのような即時モードのライブラリでも、GUIライブラリに保持させたいGUIの状態があるよね。例えばウィンドウの位置やサイズ、ユーザがスクロールした距離とかね。こんなときはユニークな識別子(親のUI内でユニークなもの)の種をeguiに提供する必要があるよ。
例えば、デフォルトではeguiはウィンドウの位置を保存するために、ウィンドウのタイトルをユニークなIDとして使用するよ。
もし同じ名前の2つのウィンドウ(それか動的な名前の1つのウィンドウ)が必要な場合は、他のIDソース(一意の整数または文字列)をeguiに提供してね。

eguiはどのウィジェットが操作されたか(例えばどのスライダーがドラッグされたか)を追跡する必要があるよ。eguiはそのためにユニークなIDを使うけど、このケースだとIDは自動的に生成されるので、ユーザは気にしなくていいよ。特に、同じ名前のボタンが2つあっても問題なし。

全体的に、IDの取り扱いはあまり遭遇しない不便さであって、大きなデメリットじゃないよ。

tristris

最小限のコードをネイティブで動かしてみる(提供されているテンプレート版)

テンプレート

eguiでGUIを表示するためのテンプレートが公開されている。

https://github.com/emilk/egui_template/

ネイティブとWASMの両方で動かせるようなコードになっていることもあって「最小限」とは言い難いけど、いくつかのウィジェットが表示された動くコードを得られる。これを元に作り込んでいくのが良さそう。

条件と準備

今回使ったeguiのバージョンは0.10.0。まだまだ開発中なので、バージョンアップでドラスティックに変わることも多いので、すぐ古くなるかも。

環境にはRustとGitがインストールされている前提。こんな文章を読んでいるのに入れてない人なんていないと思うけど。自分が試したのはWindows環境だけど、Macでもそう変わらないはず。

手順

適当なディレクトリで以下のコマンドを実行して、ローカルへコードをクローンする。

git clone https://github.com/emilk/egui_template.git
cd egui_template

cargoで実行する。

cargo run --release

以上。とても簡単。

これでこんな感じのウィンドウが表示される。

左上のテキストフィールド、スライダー、ボタンを操作したり、あと右下ではお絵描きできる。

環境によっては文字なんかのサイズが違うかも。自分の環境(高解像度ディスプレイ)では文字がちょっと小さいと思っている。これは設定を追加すれば対応できる。

初回のコンパイルはいろいろなクレートを取得するのでそれなりに時間がかかるけど、2回目以降はかなり速いので、手軽に色々試せて嬉しい。

コードの中身の概要

GUIを表示する処理はsrc/app.rsにある。

アプリの状態を持つ構造体(このテンプレートの場合はstruct TemplateApp)を作って、それにepi::Appトレイトを実装する。

epi::Appトレイトのupdateメソッドが画面更新のたびに呼び出されるので、この中で画面を構築したり、イベントを処理したりする。
つまりUI要素を増やす場合はここをいじればよいだけ、ということでとてもシンプル。フレームワークじゃなくてライブラリを謳っているのは伊達じゃない。

src/lib.rssrc/main.rsにはmain関数なんかがあって、上記の処理を呼び出している。

eframe

テンプレートのコードの1行目に、いきなりこんなのが出てくる。ほへー、何これ。

app.rs
use eframe::{egui, epi};

eframeはeguiepiのラッパーらしい。ネイティブとWASMの両方を簡単に書けるようにするためのものだとか。「egui framework」の意味らしい。

https://github.com/emilk/egui/tree/master/eframe

単なるラッパーだからなのか、ドキュメントがなさそう。まぁソースも超シンプルなので、なくても困りはしない感じ。

リポジトリに書かれている内容からすると、eguiはあくまでもライブラリであって、そのeguiをフレームワーク的に使うためのものがeframeらしい。

んで、useしてるってことは、当然Cargo.tomlに書かれている。

Cargo.toml
[dependencies]
eframe = "0.10.0" # Gives us egui, epi and web+native backends

要するにこいつを使えば、eguiを使えるってことなんだと思う。

epi

じゃあepiは何なのかというと、eguiのバックエンドを気にせずに書けるようにするためのインターフェースらしい。ニュアンス的に「egui programming interface」の略なんだと思う。

https://docs.rs/epi/0.10.0/epi/

eframeもepiも、字面だけを見てもよく分からないし、ソースを見ても「ラッパーなんだな」くらいのことしか分からないけど、まぁそんな感じのものらしいよ。えぐいえぴえぴ。

tristris

高解像度環境への対応

高解像度のディスプレイを使用している環境だと、UI部品が小さい気がする。
自分の手元の環境は27インチWQHDディスプレイ(108dpi)のWindows 10なんだけど、文字なんかのUI要素がすごく小さいと思う。

他の環境では調べていないけど、多分、表示解像度を考慮したレンダリングを自動的に行っていないからだと思われる。

ということで対応方法を調べてみた。

結論

updateメソッドの中に、以下の記述を追加して設定すればよい。

ctx.set_pixels_per_point(1.5);

ここでctx&egui::CtxRef

メソッドに渡す引数は、使用している環境(ディスプレイの解像度)によって変わってくる。

何の値をセットするのん?

この値は、表示や計算の元になっている論理的な値から物理ピクセルを計算するときの係数らしい。

ドキュメントを辿っていくと、こんな記載がある。

Also known as device pixel ratio, > 1 for HDPI screens. If text looks blurry on high resolution screens, you probably forgot to set this. Set this the first frame, whenever it changes, or just on every frame.

適当訳:デバイスピクセル比と呼ばれるもので、HDPI(高解像度)スクリーンでは1を超えるよ。高解像度の画面でテキストがぼやけて見る場合は、この設定を忘れてるのかも。最初のフレームで設定したり、変更があったときに設定したり、あるいは毎フレーム設定してね。

メソッド名にも入っている「ポイント」は、単位の「ポイント」(1/72インチ = 約0.35mm)を指し示しているっぽい。つまり、論理的な1/72インチを、画面上で何ピクセル使って表示するか、ということになる。

デフォルト値

この設定値は、次のメソッドで取得できる。

ctx.pixels_per_point()

これをひとまず適当なメソッド内、例えばupdateなんかでprintln!してみればデフォルト値が分かる。なおctxは上記と同じ&egui::CtxRef

結果、デフォルト値として1.0が取得される(はず)。とりあえず自分の環境だとそうだった。

1.0だとピクセル等倍を意味する。ということで、高解像度の環境でも、解像度を自動的に取得して設定しているわけではないみたい。

初期値のピクセル等倍は、72dpiのディスプレイなら正しい大きさになるはず。
しかし72dpiってすごくドットが大きくて荒いので、ブラウン管の時代ならともかく、今どきそんな見栄えの悪いディスプレイを使っている人なんて希少なのでは、という気がするけど、普通に売られている31.5インチのFullHD(1920x1080ピクセル)が69dpiなので、世の中には意外と多いのかもしれないとも思ったりもする。閑話休題。

セットする値

どんな値をセットすれば良いかを考えてみる。

数値は「論理的な1/72インチを何ピクセルで表示するか」なので、例えば144dpiの環境であれば、72dpiの2倍、つまり1/72インチを2ピクセルで表示するのが妥当なわけで、2.0を設定することになる。
要するに、高解像度であればあるほど大きな数字をセットすることになる。

これを別の角度から見れば、セットする値を大きくするとUI要素が大きくなる(要素を構成するピクセルの数が増える)ということ。

具体的な値は、「通常の解像度(72dpi)から比べて、どれだけ高解像度なのか」を係数にすれば良い。
したがってうちの環境(108dpi)では、以下の計算式になる。

108 / 72 = 1.5

すなわち、ctx.set_pixels_per_point(1.5);と設定すれば良い。

実際にやってみた

公式テンプレートのupdateメソッドの中に、メソッドを書き加えてみる。

app.rs
fn update(&mut self, ctx: &egui::CtxRef, frame: &mut epi::Frame<'_>) {
    ctx.set_pixels_per_point(1.5);
	...(以下略)

実際は初回のフレームだけ設定すればいいはずなんだけど、それだけに制限するのが面倒くさいので、毎フレーム設定している。上記のドキュメントにも書かれている通り、これで問題ないと思う。

結果はこんな感じ。

何も設定していないもの(初期値の1.0)と比較すると、大きさの変化が分かる。

1.5だと大きすぎでは。色々値を変えて試してみたけど、個人的には1.2程度がちょうど良い感じ。ここまでつらつらと書いてきた値の算出等は間違っている可能性大。1.5の平方根とかかなー、とも思ったけど、そんな値になる理由がないし。

備考

ドキュメントに直接書かれているわけじゃないけど、フレームの更新処理の中で設定しないと効かないので、基本的にはupdateメソッドの中で設定することになると思う。

epi::Appトレイトには、ちょうどお誂え向きに、最初に実行されるfn setup(&mut self, ctx: &egui::CtxRef)メソッドがあるんだけど、この中で設定しても効かない。理由は知らない。


あとこれ、値をコードに埋め込んで設定するのではなく、実際の環境の解像度を調べて設定するべきなんだろうと思うけど、そのやり方は試してない。自分が複数の環境で試験できないので微妙だと思って。
多分、winit::dpiを使えばいいんだろうと思っているけど、どうだろう。

tristris

ネイティブで動く本当に最小限のコードを書いてみる

公式のテンプレートを使うと簡単に環境が整うけど、あれはWASMでも実行できるようにするコードが入っていたりして、最小限のコードではない。

ということで、ネイティブだけで動かすにあたって最小限のコードを書いてみた。別に意味はないけど。

作成

Cargoを使って基本的なファイルを生成する。

適当なフォルダで以下のコマンドを実行する。

cargo new egui_simple

フォルダとファイルが生成される。

Cargo.toml

Cargo.tomldependenciesに追加する。

Cargo.toml
[dependencies]
eframe = "0.10.0"

main.rs

src/main.rsを以下のように書き換える。

main.rs
use eframe::{egui, epi};

#[derive(Default)]
pub struct SimpleApp {}

impl epi::App for SimpleApp {
    fn name(&self) -> &str {
        "SimpleApp"
    }

    fn update(&mut self, ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.label("Hello World!!");
        });
    }
}

fn main() {
    let app = SimpleApp::default();
    eframe::run_native(Box::new(app));
}

実行

以下のコマンドを実行する。

cargo run --release

これでウィンドウが表示され、ラベルに「Hello World!!」が表示される。簡単。

コードの説明

基本

テンプレートの説明のところでも書いたけど、構造体を作って、それにepi::Appトレイトを実装すればOK。

今回はアプリの状態を何も持たないので、SimpleApp構造体は空。

main関数でネイティブ実行している。

epi::Appトレイト

epi::Appトレイトは、ドキュメントを見れば分かるけど、10個強のメソッドを持っている。

ただそのほとんどにデフォルト実装があるので、最小限必要なのはnameupdateの2つのみ。

nameはウィンドウのタイトルを返す。ここで言うウィンドウは、OS側のウィンドウのこと。

updateが一番のミソ。

updateメソッド

このメソッドが、画面の更新のたび、すなわち毎秒60回とか呼び出される。

ここで画面を構成したり、イベントの処理を行ったりする。

トップレベルの要素は、以下のものにする必要がある。複数指定可。ラベルやボタンなんかのウィジェットは、その中に追加する。

  • egui::SidePanel 左右のエリア。
  • egui::TopPanel 上のエリア。
  • egui::CentralPanel 中央のメインのエリア。
  • egui::Window ウィンドウ内ウィンドウ。CentralPanel内で動かせる。
  • egui::Area ウィンドウ内エリア。CentralPanel内で動かせる。あまり使わないと思う。

それぞれ生成するときのメソッドや引数が微妙に違うので、メモとして記しておく。

egui::SidePanel::left("Left", 200.0).show(ctx, |ui| { ... // 数値は最小幅
egui::TopPanel::top("Top").show(ctx, |ui| { ...
egui::CentralPanel::default().show(ctx, |ui| { ...
egui::Window::new("Window").show(ctx, |ui| { ...
egui::Area::new("Area").show(ctx, |ui| { ...

この例の引数で渡している文字列は、その要素のID。型がimpl std::hash::Hashなので、ハッシュ化できるものなら何でもオッケー。同じ親要素内で重複してしまうと実行画面に赤くエラーが表示されるので注意。

一部のものは追加する順番に意味がある。SidePanelTopPanelのどちらを先に書くかによって、どちらが大きいかが決まる。

CentralPanelは、SidePanelTopPanelの残りの部分になる。この中にWindowAreaが表示される。

ウィジェットについては……ひとまず省略。そのうち気が向いたら書くかも。

eguiの全体まとめ

ということでeguiについて試したことを書いてみた。見た目や操作感にかなり癖があるけどシンプルで、普通に使う簡単なGUIならイケるクレートだと思う。何よりビルドが速いのが嬉しい。

ただ現時点では日本語が使用できなかったりとか、まだまだ微妙なところが多いのは事実。

でも開発は結構な勢いで進んでいるようなので、その辺りは時間と誰かが解決してくれるのではないかと期待してる。