あとで書き直すかも。いったん「これでいけそう」という方針が決まってきたので、自分の中での整理も兼ねて書きます。
作っているもの
R の graphics device です。graphics device というのはプロットなどを表示するためのウィンドウです。 R にはこのウィンドウを実装するための API があり、これを Rust でつくろうとしている、という話です。
winit とは
Rust でウィンドウの作成とその操作をクロスプラットフォームに実現する crate です。 Rust で GUI をつくりたい、と思った時にいろいろ選択肢はありますが、このウィンドウ制御の部分は多くの場合 winit が担っています。 たとえば、ちょっと前に Tauri v2 がリリースされてましたが、Tauri も winit を fork した crate を使っています。
winit を R から使いたい
さて、そんな winit を R から使うのにまず悩むのは、基本的に main thread で動かす必要がある、という点です。
winit は、だいたいこんな感じの main() を書いて実行することが想定されています。
run_app() を実行すると、event_loop() が終わる(だいたいウィンドウが閉じれば終了)まで実行がブロックされます。
fn main() { let event_loop = EventLoop::new().unwrap(); let mut app = App::default(); event_loop.run_app(&mut app); }
スタンドアローンなアプリケーションでの利用はこれでいいわけですが、R から使うには、「関数を実行したらウィンドウが開いてる間ずっとコンソールが使えない」というのでは困ってしまいます。 ではどうすればいいのかというと、いくつかやり方があります。
解決策1: run_app_on_demand() や pump_app_events() で定期的に処理を中断する
run_app_on_demand()
run_app_on_demand() を使うと、event_loop が一度 exit() しても再度利用することができます。なので、一度処理が終わったら exit() して制御を戻し、また run_app_on_demand() する、というようなことができます。公式の example のコード を見ると、こんな感じで使えます。
let mut event_loop = EventLoop::new().unwrap(); let mut app = App { idx: 1, ..Default::default() }; event_loop.run_app_on_demand(&mut app)?; println!("--------------------------------------------------------- Finished first loop"); println!("--------------------------------------------------------- Waiting 5 seconds"); std::thread::sleep(Duration::from_secs(5)); app.idx += 1; event_loop.run_app_on_demand(&mut app)?; println!("--------------------------------------------------------- Finished second loop");
ただ、これは基本的には run ごとにウィンドウを作り直すようなケースを想定しているようなので、同じウィンドウを使いまわすようなケースだと適さないのかもしれません(あまり試してなくて分からない)。
上でも、app 自体は使いまわしていますが、 idx を変えているので、最初と二度目では別のアプリが実行されていると理解していいでしょう。
It’s expected that each run of the loop will be for orthogonal instantiations of your Winit application (ドキュメント)
pump_app_events()
一方、pump_app_events() は、ウィンドウへの操作をバッファに溜めておいて、それをまとめて実行する、というモデルです。
公式の example コードを見るとこんな感じです。
ここでは 16ミリ秒(≒ 60fps)でウィンドウを更新するループを永久に回していますが、これを途中で止めて制御を他に移すこともできるでしょう。
let mut app = PumpDemo::default(); loop { let timeout = Some(Duration::ZERO); let status = event_loop.pump_app_events(timeout, &mut app); if let PumpStatus::Exit(exit_code) = status { break ExitCode::from(exit_code as u8); } sleep(Duration::from_millis(16)); }
問題は、バッファしている間はウィンドウはまったく操作を受け付けない、という点です。具体的には例えば、閉じるボタンを押してもウィンドウが閉じません。
ドキュメントを読む感じ、RedrawRequested など一部 pump_app_events() を介さずに即座に実行される例外もあるようですが、基本的には、定期的に pump_app_events() しなければ、ユーザーから(あるいは OS から)はウィンドウが動作していないように見えてしまいます。
今回は R から実行するということで、ちょっと選択肢から外れそうです。
解決策2: スタンドアローンのアプリケーションにしてしまって外から操作する
とりあえずやってみたのはこれです。スタンドアローンのサーバーとしてプロセスを動かして、何らかのプロトコルで外から操作できるようにします。 今回は gRPC で操作できるサーバーにしてみました。
proxy を介して user event を渡す
まず、winit に用意されている仕組みの説明を少しすると、EventLoop には独自に定義したイベントを渡すことができます。
EventLoop 自体は一度 run_app() してしまうと直接は操作できませんが、外側からこの独自イベントを渡すことでアプリケーションを操作できます。
まずは、イベントを定義し、EventLoop の定義に with_user_event() をつけます。
enum MyUserEvent { CloseWindow, DrawCircle { .. }, DrawLine { .. }, } let event_loop = EventLoop::<MyUserEvent>::with_user_event().build().unwrap();
この独自イベントが渡されたときにどういう処理をするかは ApplicationHandler<MyUserEvent>::user_event() に記述できます。
たとえば、MyUserEvent::CloseWindow でウィンドウを閉じたければ、window を drop して event_loop.exit() する、といった処理をここに書くことができます。
impl ApplicationHandler<MyUserEvent> for App { .... fn user_event(&mut self, event_loop: &ActiveEventLoop, event: MyUserEvent) { match event { MyUserEvent::CloseWindow => { ... } ... } } }
ではこの独自イベントをどうやって渡すかというと、EventLoop からは create_proxy() で EventLoopProxy という投げ込み口?を作ることができます。
EventLoop 自体はスレッドをまたぐことができないのですが、 EventLoopProxy はSend かつ Sync です。
let proxy = event_loop.create_proxy(); proxy.send_event(MyUserEvent::CloseWindow).unwrap();
実際の実装
gRPC サーバーの実装には tonic を使いました。tonic を動かすには tokio も必要です。
main thread は最終的には winit の EventLoop に占有されますが、その前に tokio::spawn() で 2 つスレッド?タスク?を立てています。
- gRPC サーバー
- 画面更新用に定期的に redraw request を送るだけのループ
上に書いたように、proxy は Send かつ Sync なので tokio::spawn() に渡すことができます。
#[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut app = App {}; let event_loop = EventLoop::<UserEvent>::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); let addr = "[::1]:50051".parse()?; // IP アドレスは適当 let gd= VelloGraphicsDevice::new(event_loop_proxy); tokio::spawn(async move { let _res = Server::builder() .add_service(GraphicsDeviceServer::new(gd)) .serve(addr) .await; }); let event_loop_proxy_for_refresh = event_loop.create_proxy(); tokio::spawn(async move { let mut interval = tokio::time::interval(REFRESH_INTERVAL); loop { interval.tick().await; event_loop_proxy_for_refresh .send_event(UserEvent::RedrawWindow) .unwrap(); } }); event_loop.run_app(&mut app)?; Ok(()) }
問題
とりあえず、これで動くようにはなったのですが、なんか動作がもっさりしています。↓をクリックすると動画が動きます。 もっと一瞬で表示されてほしいのですが、何フレームかかかっているように見えます。
まあ、何のチューニングもしていない状態なので、gRPC という選択が悪いのかはまだわかりません。 ただ、
- ネットワークまわりのチューニングをしていくのが大変そう
- たまに通信が切れたりとか、調査や再現が難しい問題がある
- tokio と tonic はちょっと重い
というところで、いったん別の道を模索してみることにしました。
解決策3: main thread 以外で動かす
え、main thread で動かす必要があるという話だったのでは?、という困惑が聞こえてきそうですが、実は winit は main thread 以外でも動かすことができます。
with_any_thread() というやつがそれです。ドキュメントで言うとこのあたりです。
winit::platform::windows::EventLoopBuilderExtWindowswinit::platform::x11::EventLoopBuilderExtX11winit::platform::wayland::EventLoopBuilderExtWayland
と並べたところで気付いたと思いますが、 windows、x11、wayland とあって、 macos がありません...。
そう、winit は main thread 以外でも動かせるのですが、OS によってその可否は異なり、macOS ではできないのです。
理由は調べても理解できなかったのですが、 OS 間の API の違いによるもののようです。
なのでまあ、いったん macOS は置いておくと、こんな感じで std::thread::spawn() 内で EventLoop を作って動かすことができるようになるはずです。
let handle = std::thread::spawn(|| { let event_loop = winit::event_loop::EventLoop::<UserEvent>::with_user_event() .with_any_thread(true) // 重要! .build() .unwrap(); let mut app = App::default(); event_loop.run_app(&mut app).unwrap(); });
解決策4: fork する
訂正:やってみたらふつうに無理でした...。これは macOS が歴史的経緯で?課している制約らしく、乗り越えられなさそうです。
では macOS はどうするのかというと、いろいろ調べた結果、R 側で fork するしかなさそう、ということになりました。
parallel::mcparallel() を使います。
こんな感じになるはずです。
p <- mcparallel(open_graphics_device())
今度は、え、そんなものがあるなら元から全部これ使っとけば解決だったのでは?、と思うかも知れませんが、これはこれで課題があります。
具体的には、mcparallel は Windows では使えないのです...。
(いちおう書いておくと、Windows は捨てるというのも妥当な選択肢だとは思います。今回はクロスプラットフォームなものをつくる、というモチベーションでやっているので、Windows もサポートしたいです。)
結論
ということで、いったん自分の中での結論としては、
ということにしました。 うまくいくかはわかりません。また何か困難にぶち当たったら追記しようと思います。
追記:むりだったので別プロセスで動かすことにします...
更に追記:英語ブログの方にまとめ(説明用の R パッケージ付き!)を書きました。