Rust グラフィックス周辺のクレート群まとめ
Rust グラフィックス: winit 編
winit クレートは、アプリケーションウインドウとイベントループを管理する。
GUI アプリケーションやゲームで利用されることを想定して、開発が進められている。
読み方は紹介されていないが、多分「だぶりゅー いにっと」ではないか(それか「ういん いっと」)。
主な役割は次の 4 つ。
- イベントキューを生成する
- キューから取り出したイベントを、対応するコードブロックにふり分ける
- アプリケーションウインドウを生成する
- ウインドウへ描画するためのハンドルを保持/提供する
winit クレートは、これらをうまく抽象化した、マルチプラットフォームな API を提供する。
Windows、macOS、Unix(X11、Wayland)、Android、iOS、WebAssembly(canvas) がサポートターゲットとして列挙されている[1]。
OS やウインドウシステムに固有な機能は基本的にスコープ外だが、利便性を考慮して一部のみサポートされるものもある。
利用するにはフィーチャーフラグを指定する。
イベントには、アプリケーション(開始/終了)、ウインドウ(リサイズ/最大化/最小化/再描画)、ユーザー入力(キー押下/マウス移動/タッチ)、描画デバイス(ロスト)といったカテゴリーのものが含まれる。
event_loop::EventLoop 構造体
と
window::Window 構造体
を生成するには、それぞれ new() するだけで良い。
window::Window の生成には event_loop::EventLoop の参照が必要となる。
let event_loop = EventLoop::new().unwrap();
let window = Window::new(&event_loop).unwrap();
または
let event_loop = EventLoop::new().unwrap();
let window = WindowBuilder::new().build(&event_loop).unwrap();
追加のメモ: 最新版の組み合わせ
winit 0.29.2 における window::Window を、wgpu 0.17.1 のレンダリング先サーフェスとして指定するには、cargo add winit --features rwh_05 を明示的に指定する必要がある。
-
リポジトリには「Redox」という記述もありました。これは Rust でスクラッチされたカーネル、およびオペレーティングシステムです。 ↩︎
Rust グラフィックス: wgpu 編
wgpuクレートは、GPU による描画や GPU へのネイティブアクセスをフルにサポートする。
WebGL[1] という Web 業界規格の後継として検討されている、WebGPU という新しい規格に準拠している。
読み方は紹介されていないが、多分「だぶりゅー じーぴーゆー」ではないか。
主な役割は次の 5 つ。
- WebGPU コンテキストから GPU アダプターと GPU デバイスを生成する
- GPU デバイスへのコマンドキューを生成する
- GPU デバイス上に生成可能な、頂点バッファーやテクスチャーといったリソースを管理する
- コードブロックをシェーダーとしてコンパイルする
- 描画を伴わない重たい演算処理を、コンピュートシェーダーとしてオフロードする
wgpu クレートは Rust で書かれているため安全で、またマルチプラットフォームに対応している。
wgpu を組み込んだネイティブアプリケーションは、バックエンドに Vulkan、D3D12、D3D11、Metal、OpenGLES のいずれかを選択して動作する[2]。
またブラウザーが Navigator および canvas 要素経由で WebGPU コンテキストを提供していれば、wgpu を組み込んだ WebAssembly アプリケーションが動作する。
シェーダーコードは、WebGPU で規格化された WGSL というシェーダー言語で記述する。
関数宣言が fn
、戻り値型が ->
、変数の型が後置修飾である点など、WGSL の文法は Rust の影響を強く受けているように見える。
wgpu クレートはいくつかの要素に依存している。
wgpu-core には WebGPU のランタイム本体が実装されている[3]。
wgpu-hal は、バックエンドとなるグラフィックス API 群を抽象化する。
naga もまた、バックエンドとなるシェーダー言語のコンパイラーを提供する。
WGSL を SPIR-V[4]、HLSL[5]、MSL[6]、GLSL[7] に変換することができる。
wgpu の API はとても多いのだが、リソースごとに分類されていたり、また生成についてはデスクリプターを経由するシグネチャーになっていたりしていて、多少覚えやすく設計されている。
次のコード例では GPU デバイスを示す変数 device
から、レンダーパイプラインレイアウトとレンダーパイプラインを生成している。
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: ...,
bind_group_layouts: ...,
push_constant_ranges: ...,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: ...,
layout: Some(&render_pipeline_layout),
vertex: ...,
fragment: ...,
:
});
このように生成メソッド呼び出しが device.create_<リソース種類>
という形式で統一されていて、生成リソースの詳細を指定する引数は、おおむね <リソース種類>Descriptor
という 1 つのみに集約されている。
これによって基本的には、オーバーロードやデフォルト引数がメソッドシグニチャーからは排除されている。
-
ブラウザーの canvas 要素に対する描画コンテキストの一種。JavaScript の文法で OpenGL とほぼ同じ API を利用した描画が可能。 ↩︎
-
規格に Web という名称が付いているが、wgpu を叩くアプリケーションの実行にブラウザーが必須ということではない。 ↩︎
-
…という説明になっているが、wgpu との違いはよく分からない。ちなみに WebGPU の規格書は https://gpuweb.github.io/gpuweb/ ↩︎
-
Vulkan で規格化されているシェーダー言語の中間表現。 ↩︎
-
Direct3D で使用されているシェーダー言語。 ↩︎
-
Metal で使用されているシェーダー言語。 ↩︎
-
OpenGL/ES で規格化されているシェーダー言語。 ↩︎
Rust グラフィックス: wgpu リソース編
WebGPU や wgpu を使いたいのは、得てして GPU をもっと効率的に使いたいからである。
アプリケーションを最適化して、もっとたくさんのオブジェクトを描画したり、もっとたくさんの演算がしたいのだ。
最適化のためには、シンプルだが融通が効かないライブラリを採用する代わりに、低レベルのネイティブな API をうまく組み合わせられなければならない。
wgpu はネイティブアクセスのために、
GPU デバイス
と
コマンドキュー
という 2 つのオブジェクトを提供する。
これらを取得する手順は次の通りである。
Instance <== Instance::new()
┗ Adapter <== instance.request_adapter()
┗ (Device, Queue) <== adapter.request_device()
Instance
オブジェクトから request_adapter()
メソッドを呼び出して Adapter
オブジェクトを得て、Adapter
オブジェクトから request_device()
メソッドを呼び出して Device
オブジェクトと Queue
オブジェクトを得ることができる。
ここでは省略しているが、実際にはこれらの引数に、バックエンドとなる描画システム(D3D12 など)や性能のプリファレンス(性能重視か、省エネ重視≒モバイル用途か)を指定する。
レンダリング先となるウインドウの
サーフェス
を取得する手順は次の通り。
Instance
┗ Surface <== instance.create_surface()
┗ Texture <== surface.get_current_texture()
┗ TextureView <== texture.create_view()
Instance
オブジェクトから create_surface()
メソッドを呼び出して Surface
オブジェクトを得ることができる。
アプリケーションの初期化フェーズでは、Surface
オブジェクトの configure()
メソッドを使って、レンダリングサーフェスのサイズやフォーマットや描画同期モードなどを指定する。
GPU デバイスは、GPU 上にさまざまな種類のリソースを生成する。
それぞれのリソースは別の種類のリソースに関連付けられていたり、依存したりしているが、それらのリソースの置き場所を管理するのが、GPU デバイスの主な役割になる。
平たく言えば、GPU デバイスは VRAM 上のメモリマップを管理している[1]。
いくつか代表的なリソースの生成手順を次にまとめた。
Device
┣ Texture <== device.create_texture()
┃┗ TextureView <== texture.create_view()
┣ Sampler <== device.create_sampler()
┣ BindGroupLayout <== device.create_bind_group_layout()
┣ BindGroup <== device.create_bind_group()
┣ ShaderModule <== device.create_shader_module()
┣ PipelineLayout <== device.create_pipeline_layout()
┣ RenderPipeline <== device.create_render_pipeline()
┣ CommandEncoder <== device.create_command_encoder()
┗ Buffer <== device.create_buffer_init()
先述の通り、それぞれのメソッドは create_<リソース種類>()
という命名で統一されているが、この中で Buffer
オブジェクトだけは例外となっている。
create_buffer()
というメソッドも存在はしているが、通常のユースケースでは create_buffer_init()
を使用するようである[2]。
リソースにデータを書き込むときはコマンドキューに依頼する。
queue.write_texture()
queue.write_buffer()
これらのメソッドは、それぞれテクスチャーの転送とバッファの転送を GPU に指示する。
さらにアプリケーションは、フレーム描画フェーズにおける処理のゴールとして、Queue
オブジェクトの submit()
メソッドを使ってコマンド群をサブミットする。
これによりサーフェスのレンダリング開始を GPU に指示する。
コマンド群を構築するには、CommandEncoder
オブジェクトの begin_render_pass()
メソッドから得られる RenderPass
オブジェクトを使用する。
RenderPass
オブジェクトには次のようなメソッドがある。
render_pass.set_pipeline()
render_pass.set_bind_group()
render_pass.set_vertex_buffer()
render_pass.set_index_buffer()
render_pass.set_push_constants()
render_pass.set_view_port()
render_pass.draw()
render_pass.draw_indexed()
render_pass.draw_indirect()
Rust グラフィックス: piston 編
piston クレート群は、ゲームアプリケーション開発に必要となる、デベロッパーフレンドリーでアグノスティック[1]なインターフェースを提供する。
中心となる piston クレート(文脈によっては pistoncore と呼ばれる)の主な役割は次の 4 つ。
- アプリケーションウインドウを生成するための、統一されたインターフェースを提供する
- キーボード・マウスなどの入力を扱うための、統一されたイベント定義を提供する
- イベントハンドラーを生成するための、統一されたインターフェースを提供する
- ウインドウへ描画するためのハンドルを保持/提供する
pistoncore はマルチプラットフォームな API をトレイトとして宣言するが、実装までは提供していない。
ウインドウ生成の実装については GLFW、SDL2、winit、Glutin[2] といったバックエンドがサポートされていて、ビルドするにはそれぞれ pistoncore-glfw_window、pistoncore-sdl2_window、pistoncore-winit_window、pistoncore-glutin_window というクレートが必要となる。
デベロッパーは Cargo.toml の dependencies セクションに pistoncore と、これら pistoncore-window 実装の中から自身が使いたいと思うバックエンドクレートの両方を追加する。
もうひとつ、piston クレート群で中心となるものに piston2d-graphics クレートがある。
主な役割は次の 2 つ。
- 2D 図形を描画するのに必要となる、基礎的な概念を提供する
- 線、三角形、四角形、ポリゴン、円/円弧/楕円、文字/文字列を描画するための、統一されたインターフェースを提供する
基礎的な概念には、色、画像、角度、ビューポート、2 次元ベクトル、2x2 行列、座標変換、クリッピングといったものが含まれる。
piston2d-graphics クレートは、マルチプラットフォームな 2D 描画 API をトレイトとして宣言するが、実装までは提供していない。
2D 描画の実装については OpenGL、gfx[3]、wgpu、Glium[4] といったバックエンドがサポートされていて、ビルドするにはそれぞれ piston2d-opengl_graphics、piston2d-gfx_graphics、piston2d-wgpu_graphics、piston2d-glium_graphics というクレートが必要となる。
このように piston のデザインポリシーでは、piston クレート群の側にはバックエンドとなるクレートへの依存は宣言されない。
ゲームアプリケーションのメンテナンスの手間を考えると、依存の少なさはひとつのメリットにもつながる。
piston を使うゲームアプリケーションの main()
関数では、まず次のようにウインドウを生成する。
ここではファクトリーパターンを適用している。
fn main() {
let opengl = OpenGL::V3_2;
// Create a Glutin window.
let mut window: GlutinWindow = WindowSettings::new("spinning-square", [200, 200])
.graphics_api(opengl)
.exit_on_esc(true)
.build()
.unwrap();
:
piston::WindowSettings
でウインドウに関する設定を保持する構造体を準備しておいて、piston::WindowSettings::build<W: BuildFromWindowSettings>()
をコールすることでウインドウを生成している。
バックエンドに Glutin を使用しているときは、ジェネリックパラメーター W
が glutin_window::GlutinWindow
型であることが推論によって特定される。
アプリケーションは piston クレートが提供する抽象型(たとえば piston::Window
というような)を利用するのではなく、バックエンドクレートが提供するウインドウ型をそのまま利用するデザインになっている。
こうすることで piston クレートの API は、バックエンドとなるウインドウシステムに特有の機能について考慮する必要がなくなる。
それでいてデベロッパーは、そのようなウインドウシステムに特有の機能をいつでもアプリケーションに組み込むことができる。
アプリケーションは WindowSettings
オブジェクトの .graphics_api()
メソッドで、使用したいグラフィックス API を指定している。
ここで指定されたグラフィックス API に対応するサーフェスの生成方法を glutin_window が知っている場合、.build()
メソッドは指定に沿ってウインドウを生成する。
そしてゲームアプリケーションのメインループは次のようになる。
:
let mut events = Events::new(EventSettings::new());
while let Some(e) = events.next(&mut window) {
if let Some(args) = e.render_args() {
app.render(&args);
}
if let Some(args) = e.update_args() {
app.update(&args);
}
}
}
ゲームアプリケーションの update()
メソッドと render()
メソッドは、キューから得られたイベントに応じてコールする。
もちろんどちらかだけをコールするケースもある。
更新と描画のフレームレートは EventSettings
で設定する。
-
agnostic、不可知論的という意味。Wikipedia の説明によると、低レイヤーに起因する仕様・制限について関知しないインターフェースデザインであることを指すらしい。 ↩︎
-
Glutin は OpenGL コンテキストのプロバイダー。Windows、Linux、OSX などのマルチプラットフォームに対応している。 ↩︎
-
gfx は 3D 描画に対応したグラフィックスライブラリ。wgpu の構成要素の一部にもなっている。後日調査予定。 ↩︎
-
Glium は OpenGL3 以降を対象とする、高レベルの描画 API ラッパー。Glutin に依存しており、Windows、Linux、OSX などのマルチプラットフォームに対応している。2023 年 11 月現在、リポジトリはメンテナンスモードになっている。 ↩︎
Rust グラフィックス: piston プロジェクト
piston プロジェクトは、2D・3D 描画、イベントプログラミング、AI、画像加工のライブラリ群でありながら、多くのデベロッパーによるコラボレーションの場でもある。
piston のコミュニティーは「piston クレート群にどの機能を含めるか?」ではなく、「どのような機能とインターフェースが、グラフィカルアプリケーションの開発生産性を高めるか?」を重視している。
ここまで pistoncore と piston2d-graphics で見てきたように、それぞれの機能をモジュール化(デカップリング)して、自由に組み合わせられることこそが、piston プロジェクトのゴールになっている[1]。
たとえば
piston_collada
と
wavefront_obj
は、メッシュオブジェクトのデータ形式を定義し、データファイルの読み込みをサポートする。
これらは piston を使用するアプリケーションと容易に組み合わせられる。
dyon
は、Rust 風の動的型付けスクリプト言語の実行環境をサポートする。
これも piston を使用するゲームアプリケーションと容易に組み合わせられる。
dyon をうまく活用することで、個々のゲームアクターのふるまいをゲームエンジンのコードベースから分離することができるかもしれない。
piston2d-sprite
は、スプライトによるアニメーションをサポートする。
イージングもカバーされている。
piston3d-gfx_voxel
は、ボクセルで表現されたワールドを gfx ライブラリで描画する。
別のコミュニティでは、この piston3d-gfx_voxel を使って Minecraft 風のアプリケーション
hematite
を開発している。
-
ここまで「piston クレート群」という表現を使っているが、結局のところ「どこまでが piston ライブラリのスコープなのか?」を規定するガイドラインは明確ではない。 ↩︎
Rust グラフィックス: SDL2 編
Rust-SDL2 クレートは SDL2 の Rust バインディングである。
SDL(Simple DirectMedia Layer)とは、オーディオ、キーボード、マウス、ジョイスティックや、OpenGL/Direct3D を経由したグラフィックハードウェアへの低レベルアクセスのために設計された、クロスプラットフォームなライブラリであり、SDL2 はそのバージョン 2 を指す。
Windows、macOS、Linux、iOS、Android に加えて、さまざまなコンソールゲームデバイスにも対応している。
主な役割は次の 7 つ。
- アプリケーションウインドウを生成する
- イベントキューを生成する
- ウインドウへ描画するためのハンドル(キャンバス)を保持/提供する
- 点、直線、矩形、テクスチャーなどの 2D 図形をキャンバスやテクスチャーに描画する
- サウンドデバイスへデータサンプルを出力する
- ウインドウから OpenGL(または OpenGL ES)のコンテキストを生成する
- Vulkan インスタンスを生成して、ウインドウサーフェスへ描画する
これ以外にもファイルシステム、ログ、タイマー、ストリーム入出力、クリップボードなどの、マルチプラットフォームなユーティリティーが揃っていて、ゲームプログラミングを学ぶ道具がひと揃いになっている。
Rust-SDL2 クレートはバインディングだけを提供するため、SDL2 ランタイムそのものを含んでいない。
Rust-SDL2 を利用するアプリケーションをビルドするには、以下の手順にしたがって SDL2 のランタイムを別途入手する[1][2]。
ウインドウとキャンバスを生成するには、次のようにメソッドをコールする。
ウインドウの生成には、ファクトリーパターンが使われている。
pub fn main() {
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem.window("rust-sdl2 demo", 800, 600)
.position_centered()
.build()
.unwrap();
let mut canvas = window.into_canvas().build().unwrap();
:
イベントキューは SDL2 のコンテキストから .event_pump()
メソッドで生成する。
ウインドウイベントや、入力デバイス(キーボード、マウス、ゲームパッド)からのイベントにも対応している。
:
let mut event_pump = sdl_context.event_pump().unwrap();
let mut i = 0;
'running: loop {
:
for event in event_pump.poll_iter() {
match event {
Event::Quit {..} |
Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
break 'running
},
_ => {}
}
}
// The rest of the game loop goes here...
canvas.present();
::std::thread::sleep(Duration::new(0, 1_000_000_000u32 / 60));
}
キャンバスへの描画は、とても直感的で on-the-fly な手順になる。
次に示すコードの通り、.set_draw_color()
の呼び出しはキャンバスのグローバルな設定を変更し、それに続く図形描画メソッド(.clear()
と .fill_rect()
)の色を決定する。
canvas.set_draw_color(Color::RGB(0, 0, 0));
canvas.clear();
canvas.set_draw_color(Color::RGB(255, 210, 0));
canvas.fill_rect(Rect::new(10, 10, 780, 580));
canvas.present();
SDL2 には、コアランタイムから分離された、いくつかのサポートランタイムが存在している。
たとえば SDL2_image は、.png、.bmp などの画像ファイルからピクセルデータを取得する。
そのようなピクセルデータは、テクスチャーとして 2D 図形の描画に利用することができる。
Rust アプリケーションから SDL2_image を利用するには、SDL2_image のランタイムを別途入手する[3]。
そして Rust-SDL2 のフィーチャーフラグのひとつ image を指定する必要がある。
cargo add sdl2 --features image
SDL2 ランタイムに備わっているサウンドデバイスの API は、音源を単純再生することしかできない。
SDL2_mixer は楽曲と効果音の再生をミキシングして、チャネルごとのデータサンプルを合成する。
ゲームアプリケーションにおける利用シナリオのように、ボリュームを指定しつつ複数の音源をミキシングするといった機能を提供する。
Rust アプリケーションから SDL2_mixer を利用するには、SDL2_mixer のランタイムを別途入手する[4]。
そして Rust-SDL2 のフィーチャーフラグのひとつ mixer を指定する必要がある。
cargo add sdl2 --features mixer
Rust-SDL2 クレートは、OpenGL/ES コンテキストや Vulkan インスタンスを生成することができるものの、OpenGL/ES や Vulkan の仕様に沿ったグラフィックス API を提供しない。
そのようなグラフィックス API を利用した Rust アプリケーションを開発するには、gl-rs クレートや vulkano クレートなどを別途導入する。
このような利用シナリオでは、SDL2 ランタイムは各グラフィックス API のウインドウシェルのような使われ方となり、SDL2 の描画 API は基本的に使われない[5]。
-
ビルド済み SDL2 ランタイムは、SDL2 release-2.28.5 から入手する。Windows 11 で SDL2 を利用する Rust アプリケーションをビルドするには、SDL2-devel-2.28.5-VC.zip をダウンロードして、アーカイブの中の \SDL2-2.28.5\lib\x64*.lib をアプリケーションのプロジェクトフォルダーにコピーする。(プロジェクトフォルダーの代わりに %USERPROFILE%.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib\rustlib\x86_64-pc-windows-msvc\lib\ にコピーしても良いらしい。以降も同様。) ↩︎
-
クレート入手時に
cargo add sdl2 --features bundled
とすることで、入手と同時に SDL2 ソースコードからランタイムをビルドする選択肢もある。これには C ツールチェインが必要となる。 ↩︎ -
ビルド済み SDL2_image ランタイムは、SDL2_image release-2.6.3 から入手する。SDL2_image-devel-2.6.3-VC.zip をダウンロードして、アーカイブの中の \SDL2_image-2.6.3\lib\x64\SDL2_image.lib をアプリケーションのプロジェクトフォルダーにコピーする。 ↩︎
-
ビルド済み SDL2_mixer ランタイムは、SDL2_mixer release-2.6.3 から入手する。SDL2_mixer-devel-2.6.3-VC.zip をダウンロードして、アーカイブの中の \SDL2_mixer-2.6.3\lib\x64\SDL2_mixer.lib をアプリケーションのプロジェクトフォルダーにコピーする。 ↩︎
-
ただしある程度の制約を守れば、OpenGL/ES と SDL2 の描画 API を混在利用することは不可能ではない。 ↩︎
Rust グラフィックス: tiny-skia 編
tiny-skia クレートは、Skia に強く影響を受けて開発された、2D ラスタライザーである。
Skia というのは、Google Chrome や ChromeOS や Firefox など広く利用されている 2D 描画ライブラリを指す。
tiny-skia クレートは Skia のサブセットを Rust に移植したものと説明されるが、API の互換性はなく、また GPU レンダリングにも対応していない[1]。
主な役割は次の 2 つ。
- 2D 図形やパスの描画、グラデーションやパターンを使った塗りつぶしをピクセルマップに対しておこなう
- .png ファイルとピクセルマップを相互に変換する
次のコードは、500x500 のピクセルマップの中心に、星の形をした緑色の点線を引いている。
fn main() {
let mut paint = Paint::default();
paint.set_color_rgba8(0, 127, 0, 200);
paint.anti_alias = true;
let path = {
let mut pb = PathBuilder::new();
const RADIUS: f32 = 250.0;
const CENTER: f32 = 250.0;
pb.move_to(CENTER + RADIUS, CENTER);
for i in 1..8 {
let a = 2.6927937 * i as f32;
pb.line_to(CENTER + RADIUS * a.cos(), CENTER + RADIUS * a.sin());
}
pb.finish().unwrap()
};
let mut stroke = Stroke::default();
stroke.width = 6.0;
stroke.line_cap = LineCap::Round;
stroke.dash = StrokeDash::new(vec![20.0, 40.0], 0.0);
let mut pixmap = Pixmap::new(500, 500).unwrap();
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
pixmap.save_png("image.png").unwrap();
}
図形を描画するには、まず図形の形状を定めるパスを作成しなければならない。
PathBuilder
を作成して .move_to()
で始点を指定したら、あとは .line_to()
で通過点や制御点を繰り返し追加していくことで、図形を表現していく[2]。
表現した図形は .finish()
でパスとして取り出すことができる。
次にパスを指定して .stroke_path()
を呼び出せば、輪郭をピクセルマップに描画することができる。
.stroke_path()
の代わりに .fill_path()
を呼び出せば、塗りつぶしにすることもできる。
tiny-skia クレートの特徴は、実装が単純でミニマルな点である。
たとえば依存するモジュール数は、cfg-if クレートや bytemuck クレートなどの定番も含めてたった 17 個しかない[3]。
これは他の 2D 描画ライブラリの中では、もっとも少ない部類に入る。
tiny-skia クレートは描画を CPU のみで行うため、頻繁なアニメーションを伴うアプリケーションや大量のピクセルマップを扱うゲームプログラミングには不向きである。
一方で、tiny-skia クレートの API は .svg ファイルや CSS3 スタイリングとの親和性が高い。
これらの Web 標準に従ったラスタライズが必要となる場面では、tiny-skia クレートは充分活躍できるだろう[4]。
Rust グラフィックス: lyon 編
lyon クレートは、2D 図形を描画するためのテッセレーターである。
複雑なパスを三角形リストに分割できるので、独自のレンダリングエンジンに組み込んだり、SVG のベクターグラフィックスをレンダリングするのに使える。
読み方は紹介されていないが、多分「らいおん」ではないか。
主な役割は次の 2 つ。
- 2D 図形や制御点などからパスを定義する
- パスを頂点とインテックスの配列に変換する
たとえば長方形を描画する場合は、まず Path::builder()
でパスビルダーを生成し、通過点を .begin()
と .line_to()
と .end()
で追加していくことで、長方形パスを定義する[1]。
VertexBuffers::new()
でバッファを準備したら、次に FillTessellator
オブジェクトの .tessellate()
メソッドにバッファと長方形パスを指定する。
これで頂点配列とインデックス配列をバッファに格納することができる。
長方形を塗りつぶす代わりに、枠だけを描画することもできる。
その場合は FillTessellator
オブジェクトの代わりに StrokeTessellator
オブジェクトを使用すればよい。
長方形や楕円などの基本図形の場合は、通過点や制御点をひとつずつ指定する代わりに専用のメソッドを介してパスを生成することもできる。
構築された頂点配列とインデックス配列は、たとえば wgpu を使ってレンダリングすることができる。
次の図はふたつのパス(Rust ロゴと矢印)を使って構成されている[2]。
Rust グラフィックス: miniquad 編
miniquad クレートは、レンダーパイプラインに対応した描画ライブラリである。
主な役割は次の 4 つ。
- アプリケーションウインドウを生成する
- ウインドウイベントと入力イベント(キーボードとマウスとファイルドロップのみ)をアプリケーションにふり分ける
- ウインドウへ描画するためのハンドルを保持/提供する
- GPU リソースを保持し、変更する手段を提供する
miniquad クレートは、これらをうまく抽象化したマルチプラットフォームなインターフェースを提供する。
描画バックエンドには OpenGL 3、OpenGL 2.2、GLES 3、GLES 3、Metal、WebGL 1 を選択することができる。
実行環境としては Windows、Linux(X11、Wayland)、macOS、iOS、Android、WASM 環境がサポートされている。
このように多くのプラットフォームで動作するにも関わらず、可能な限り軽量になるよう設計されている。
ここでいう軽量とは、単純にいえばコンパイル時間の短さと、依存するクレート数の少なさを指す。
サンプルコードをクリーンビルドした場合でも、大抵 5 秒前後しか掛からない[1]。
依存クレート数も 3 つだけ[2]と必要最小限であり、基本的に環境に依存したものだけとなっている。
ウインドウを表示して、単一色で塗りつぶすだけのサンプルコードは次のようになる。
struct Stage {
ctx: GlContext,
}
impl EventHandler for Stage {
fn update(&mut self) {}
fn draw(&mut self) {
self.ctx.clear(Some((0., 1., 0., 1.)), None, None);
}
}
fn main() {
miniquad::start(
conf::Conf {
window_title: "Miniquad".to_string(),
window_width: 1024,
window_height: 768,
fullscreen: true,
..Default::default()
},
|| {
Box::new(Stage {
ctx: GlContext::new(),
})
},
);
}
コードの構造的には、main()
の中でいきなり miniquad::start()
を呼び出すだけとなっている。
ひとつ目の引数でウインドウの詳細を、ふたつ目の引数でメインループのイベントハンドラーを、それぞれ指定している。
イベントハンドラーには update()
メソッドと draw()
メソッドの実装が必要だ。
このコード例では、ウインドウの塗りつぶしに miniquad::graphics::GlContext
オブジェクトの clear()
メソッドを利用している。
実行環境によっては、GlContext
の代わりに miniquad::graphics::MetalContext
オブジェクトを利用することもできる。
これらのコンテキストはどちらも RenderingBackend
トレイトを満たしており、グラフィカルアプリケーションは RenderingBackend
トレイトから得られるリソースを駆使して描画していくことになる。
RenderingBackend
トレイトが提供するメソッドは 、DX11 世代のレンダーパイプラインをほどよく抽象化したものとなっていて、miniquad が提供するインターフェースの核心であると言える。
このトレイトから得られるリソースには、次のようなものがある。
RenderingBackend
┣ ShaderId <== context.new_shader()
┣ TextureId <== context.new_texture()
┃┗ RawId <== context.texture_raw_id()
┣ RenderPass <== context.new_render_pass()
┣ Pipeline <== context.new_pipeline()
┗ BufferId <== context.new_buffer()
このうちシェーダーやテクスチャーやバッファーなどの生成メソッドは、ID(ハンドル)を返す。
これらのリソースの実体となるデータや詳細パラメーターはコンテキストの中で管理されており、それらの詳細を更新するには、ID を指定して RenderingBackend
の別のメソッドを呼び出す。
次に示すのは、テクスチャーの詳細を更新するメソッドの一例だ。
ひとつ目の引数がすべて TextureId
型となっていて、どのテクスチャーに関する操作なのかを示している。
fn texture_set_wrap(&mut self, texture: TextureId, wrap_x: TextureWrap, wrap_y: TextureWrap)
fn texture_set_min_filter(&mut self, texture: TextureId, filter: FilterMode, mipmap_filter: MipmapFilterMode)
fn texture_set_mag_filter(&mut self, texture: TextureId, filter: FilterMode)
fn texture_resize(&mut self, texture: TextureId, width: u32, height: u32, source: Option<&[u8]>)
グラフィカルアプリケーションをマルチプラットフォームでリリースすると、現実問題として環境やハードウェアに依存するバグに遭遇することも少なくない。
幸い miniquad クレートの抽象度は高くないので、修正も難しくないことが期待できる。
そのような不具合回避のための専用メソッドを、GlContext
のようなコンテキスト側に組み込むこともできるだろう。
miniquad クレートは、そういった改造のしやすさ(hackability)にも優れていると捉えることができる[3]。
miniquad クレートのドキュメントには Non-goals についでも触れられている。
念のため紹介しておこう。
- 究極的な型安全を提供するものではない
- ハイエンド向けのインターフェースではない。Vulkan や DX12 世代の API を目指すものではない[4]
Rust グラフィックス: macroquad 編
macroquad クレートは、ゲーム開発のためのシンプルでやさしいライブラリである。
Rust に詳しくない開発者のために、ライフタイムや借用といった Rust 特有の概念を排除したインターフェースで構成されている。
主な役割は次の 7 つ。
- アプリケーションウインドウを生成する
- ウインドウイベントと入力イベント(キーボードとマウス)をアプリケーションにふり分ける[1]
- 2D/3D 図形を描画するのに必要となる、基礎的な概念を提供する
- 2D 図形を効率的に描画する
- 文字列やイミディエートな UI を描画する
- 3D 図形やメッシュを描画する
- 楽曲や効果音をミキシングして発音する[2]
これ以外にもファイルシステム、疑似乱数、ロギング、タイマー、テレメトリーなどのユーティリティーが揃っていて、ゲームプログラミングを学ぶ道具がひと揃いになっている[3]。
マルチプラットフォームなインターフェースが利用でき、実行環境としては各種 PC 環境、iOS、Android、WASM 環境がサポートされている。
見てのとおりさまざまな機能を備えているが、依存するクレートの数は極力少なくなるよう実装されている。
例えば Windows 環境でサンプルコードをビルドすると、依存クレートは約 60 ほどで、クリーンビルドした場合でも 20 秒前後で済む[4]。
ウインドウを表示して、簡単な 2D 図形を表示するだけのサンプルコードは次のようになる。
use macroquad::prelude::*;
#[macroquad::main("BasicShapes")]
async fn main() {
loop {
clear_background(RED);
draw_line(40.0, 40.0, 100.0, 200.0, 15.0, BLUE);
draw_rectangle(screen_width() / 2.0 - 60.0, 100.0, 120.0, 60.0, GREEN);
draw_circle(screen_width() - 30.0, screen_height() - 30.0, 15.0, YELLOW);
draw_text("IT WORKS!", 20.0, 20.0, 30.0, DARKGRAY);
next_frame().await
}
}
ウインドウの作成に必要な処理は、すべてアトリビュートマクロ macroquad::main()
に隠蔽されている。
あとは main()
メソッドの中にメインループを配置しただけの構造となっている。
ループの中では背景を塗りつぶしたあと、4 つの図形を表示するよう指示している。
ループの最後に配置された next_frame().await
が、イベントキューからの描画リクエスト待ちを担っている。
グラフィックスのバックエンドは miniquad クレートが担っているが、macroquad クレートは miniquad とは異なる on-the-fly なグラフィックスインターフェースを開発者に提供する[5]。
プリミティブではあるが、非常に直感的なインターフェースになっている。
主な 2D 描画メソッドを次に示す。
clear_background(): 背景を塗りつぶす
draw_line(): 直線を描く
draw_triangle(): 三角形を描く
draw_triangle_line(): 三角形の輪郭を描く
draw_rectangle(): 四角形を描く
draw_rectangle_line(): 四角形の輪郭を描く
draw_poly(): 多角形を描く
draw_poly_line(): 多角形の輪郭を描く
draw_circle(): 円を描く
draw_circle_line(): 円の輪郭を描く
draw_text(): 文字を描く
draw_texture(): 画像を描く
macroquad クレートは 3D 図形の描画にも対応している。
ウインドウを表示して、簡単な 3D 図形を表示するだけのサンプルコードは次のようになる。
use macroquad::prelude::*;
#[macroquad::main("3D")]
async fn main() {
let rust_logo = load_texture("examples/rust.png").await.unwrap();
let ferris = load_texture("examples/ferris.png").await.unwrap();
loop {
clear_background(LIGHTGRAY);
// Going 3d!
set_camera(&Camera3D {
position: vec3(-20., 15., 0.),
up: vec3(0., 1., 0.),
target: vec3(0., 0., 0.),
..Default::default()
});
draw_grid(20, 1., BLACK, GRAY);
draw_cube_wires(vec3(0., 1., -6.), vec3(2., 2., 2.), DARKGREEN);
draw_cube_wires(vec3(0., 1., 6.), vec3(2., 2., 2.), DARKBLUE);
draw_cube_wires(vec3(2., 1., 2.), vec3(2., 2., 2.), YELLOW);
draw_plane(vec3(-8., 0., -8.), vec2(5., 5.), Some(&ferris), WHITE);
draw_cube(
vec3(-5., 1., -2.),
vec3(2., 2., 2.),
Some(&rust_logo),
WHITE,
);
draw_cube(vec3(-5., 1., 2.), vec3(2., 2., 2.), Some(&ferris), WHITE);
draw_cube(vec3(2., 0., -2.), vec3(0.4, 0.4, 0.4), None, BLACK);
draw_sphere(vec3(-8., 0., 0.), 1., None, BLUE);
// Back to screen space, render some text
set_default_camera();
draw_text("WELCOME TO 3D WORLD", 10.0, 20.0, 30.0, BLACK);
next_frame().await
}
}
メインループに至るコードの構造は、2D を描画するときとほとんど変わっていない。
描画に使うメソッドが 3D 用のものに置き換わっているだけなので、その点でも開発者フレンドリーと言えるだろう。
主な 3D 描画メソッドを次に示す。
set_camera(): カメラのトランスフォームを指定する
draw_grid(): グリッド平面を描く
draw_line_3d(): 直線を描く
draw_plane(): 四角形を描く
draw_cube(): 直方体を描く
draw_cube_wires(): 直方体をワイヤーで描く
draw_sphere(): 球を描く
draw_sphere_wires(): 球をワイヤーで描く
draw_mesh(): メッシュを描く
ちなみに draw_mesh()
の入力は、頂点配列(ローカル座標+テクスチャー座標+頂点カラー)、インデックス配列、テクスチャーハンドルとなっている。
macroquad クレートではライティング/シェーディングはサポートされていないため、これらのメソッドを使って立体感を表現するのは困難であろう[6]。
macroquad::material::load_material()
というメソッドを使えば、頂点シェーダーやフラグメントシェーダーを指定することもできる。
シェーダー言語には GLSL を使う[7]。
リポジトリにあるサンプルコード shadertoy を実行している様子を次に示す。
これは Shadertoy の簡易的なクローンだ。
右側には Rust ロゴがテクスチャリングされたプリミティブが表示されている。
テキストエリアの文字列はいつでも編集可能になっていて、有効な GLSL として評価されればプリミティブに即時反映される。
-
ゲームパッドの対応も予定されているようだ。今後に期待。 ↩︎
-
オーディオ機能を利用するには、macroquad を Cargo.toml に追加するときに --features audio オプションが必要となる。 ↩︎
-
ロギング機能を利用するには、macroquad を Cargo.toml に追加するときに --features log-rs オプションが必要となる。 ↩︎
-
v0.4.4 時点。 ↩︎
-
miniquad クレートが re-export されているため、miniquad のインターフェースを使った描画も可能。 ↩︎
-
ライトベイク済みの輝度を
draw_mesh()
に指定する頂点カラーとして与えることで、立体感を出すことは不可能ではないが。 ↩︎ -
リファレンスによれば MSL(Metal Shader Language)も
ShaderSource
として利用可能のように見える。未評価。 ↩︎
Rust グラフィックス: vulkano 編
vulkano クレートは、Vulkan の Rust バインダーである。
読み方は紹介されていないが、多分「ぶるかーの」ではないだろうか(あるいは「ぼるかーの」「ぼるけーの」)。
主な役割は次の 1 つだけ。
- Vulkan インターフェースを提供する
vulkano クレートはバインダーなので、正常に動かすには Vulkan ランタイムが必要になる。
Vulkan ランタイムはすでに多くの OS でサポートされているため、導入作業は基本的にしなくてすむ場合が多い[1]。
crates.io を調べてみると、Rust で使える Vulkan バインダーはほかにもいくつか存在するが[2]、vulkano クレートにはいくつか注目すべき特徴がある。
主な特徴について触れていこう。
簡便なインターフェース
Vulkan インターフェースとしては Vulkan C API がよく知られている。
C 言語ヘッダーで表現される都合上、開発者には使いづらいと感じる部分が少なくなかった。
vulkano クレートはこれとほとんど等しいインターフェースを提供するが、そこにオブジェクトの概念やコレクションの導入、各種 CreateInfo
構造体のデフォルト値の提供などによって、その使い勝手を高める役割が備わっている[3]。
メソッドや構造体などに使われるシンボルの命名は、元の Vulkan C API を踏襲している。
したがって、Vulkan を使ったことがある開発者であれば戸惑うことは少ないだろう。
シンボル名の具体例をいくつか示す。
Vulkan C | vulkano |
---|---|
VkInstance |
vulkano::instance::Instance |
vkCreateInstance() |
vulkano::instance::Instance::new() |
VkInstanceCreateInfo |
vulkano::instance::InstanceCreateInfo |
VkImage |
vulkano::image::Image |
vkCreateImage |
vulkano::image::Image::new() |
VkImageCreateInfo |
vulkano::image::ImageCreateInfo |
次のコード片は、アプリケーションの初期化を手助けする。
この select_physical_device()
メソッドは、アプリケーションにとって最適な物理デバイスを Vulkan インスタンスから取得する。
pub fn select_physical_device(
instance: &Arc<Instance>,
surface: &Arc<Surface>,
device_extensions: &DeviceExtensions,
) -> (Arc<PhysicalDevice>, u32) {
instance
.enumerate_physical_devices()
.expect("failed to enumerate physical devices")
.filter(|p| p.supported_extensions().contains(device_extensions))
.filter_map(|p| {
p.queue_family_properties()
.iter()
.enumerate()
.position(|(i, q)| {
q.queue_flags.contains(QueueFlags::GRAPHICS)
&& p.surface_support(i as u32, surface).unwrap_or(false)
})
.map(|q| (p, q as u32))
})
.min_by_key(|(p, _)| match p.properties().device_type {
PhysicalDeviceType::DiscreteGpu => 0,
PhysicalDeviceType::IntegratedGpu => 1,
PhysicalDeviceType::VirtualGpu => 2,
PhysicalDeviceType::Cpu => 3,
_ => 4,
})
.expect("no device available")
}
まず .enumerate_physical_devices()
メソッドによって物理デバイスを列挙し、エラーが起きていれば .expect()
メソッドによってエラー出力がされて、プログラムはパニック終了する。
エラーが起きていなければ、.filter()
と .filter_map()
によって最低限満たしたい機能をもち、グラフィックスパイプラインに対応し、かつウインドウサーフェスと互換のあるものだけを候補に残す。
それらの中からデバイスタイプによる重みづけの結果、ひとつの物理デバイスとキューファミリーインデックスを返す。
Rust で標準的に使える Option<>
、Result<>
、Arc<>
といったプリミティブが効果的に導入されており[4]、インターフェース呼び出しの前後も Rust らしくキレイに整理されていることが伺える。
安全なインターフェース、Undefined behavior の予防
Undefined behavior(以下 UB)とは、プログラミングにおける未定義動作のことを指す。
問題のあるインターフェースの組み合わせを記述してしまった結果、コンパイルは通るのに、よくわからない挙動や予測ができない結果に至ってしまう状況を指す。
vulkano クレートには、Vulkan インターフェースを使う上で UB に陥りやすい落とし穴を回避する仕掛けがいくつか備わっている。
Vulkan における VkBuffer
や VkImage
といった GPU リソースが、そのような仕掛けの主な対象となる。
次のコード片は、vulkano クレートを使ってデータ(イテレーター)からバッファ buf
を作成している。
let buf = Buffer::from_iter(
memory_allocator.clone(),
BufferCreateInfo {
usage: BufferUsage::TRANSFER_DST,
..Default::default()
},
AllocationCreateInfo {
memory_type_filter: MemoryTypeFilter::PREFER_HOST | MemoryTypeFilter::HOST_RANDOM_ACCESS,
..Default::default()
},
(0..1024 * 1024 * 4).map(|_| 0u8),
)
.expect("failed to create buffer");
このコードから得られる Buffer
オブジェクトは Vulkan の VkBuffer
と対応しており、Buffer::from_iter()
は Vulkan の vkCreateBuffer()
に対応している。
Vulkan の作法にしたがうなら Buffer
や Image
の作成には、GPU メモリをあらわす VkDeviceMemory
があらかじめ作成されている必要があるはずだ。
vulkano クレートでは、その部分が Buffer
オブジェクトを作成するときの引数によって、自動的に解決されるようになっている[5]。
安全なコードを組み合わせているなら、開発者が未定義な挙動に悩まされることはない。
そのような安全安心を担保することが vulkano の目的のひとつにもなっている[6]。
必要最小限の同期オブジェクト支援
Vulkan にはいくつかの同期オブジェクトが導入されている。
Vulkan では、コマンドキューの使用や GPU リソースへのアクセスにはフェンスやセマフォの導入が必要であり、また効率よくレンダリングするには同期オブジェクトの適切な連携を注意深く設計し実装しなければならない。
vulkano クレートは、よく使われるであろう処理について同期のためのメソッドを提供する。
次のコード片は、コマンドキューに GPU コマンドを積んでいる。
let future = sync::now(device.clone())
.then_execute(queue.clone(), command_buffer)
.unwrap()
.then_signal_fence_and_flush()
.unwrap();
まずはじめに sync::now()
では論理デバイスが使えるようになるのを待機している。
そして .then_execute()
ではコマンドキューに GPU コマンドを積んでいる。
つづけて .then_signal_fence_and_flush()
ではフェンスを生成して、積んだ GPU コマンドの完了を検知できるようセットアップしながら、コマンドキューをフラッシュしている。
この処理の結果、変数 future
にはフェンスオブジェクトが設定される。
ここまでは GPU に処理をまかせながらも、CPU は一切ブロッキングされないことに注目したい。
キューに積んだ GPU コマンドの完了を CPU 側が待ちたいときは、次のようにする[7]。
future.wait(None).unwrap();
この future
がシグナル状態になることは、GPU コマンドの完了を待つことと同じである。
マクロによるシェーダーコードとの統合
vulkano に関連している vulkano-shaders というクレートを追加で導入すると、GLSL コードをアプリケーションのコードベースとして扱うのが簡単になる。
次のコード片は、アプリケーションの .rs ファイルに直接 GLSL コードを配置するときの例だ。
mod fs {
vulkano_shaders::shader! {
ty: "fragment",
src: r"
#version 460
layout(location = 0) out vec4 f_color;
void main() {
f_color = vec4(1.0, 0.0, 0.0, 1.0);
}
",
}
}
vulkano_shaders::shader!
マクロはふたつの機能を提供する。
アプリケーションのビルドステップでは、GLSL コードをシェーダーコンパイラー shaderc によってビルドして、SPIR-V 形式のコードを生成する[8]。
そしてビルドが通ると、このマクロ自体を SPIR-V コードをカプセル化したオブジェクトに置き換える。
そのオブジェクトを使って SPIR-V コードをロードしたり、リフレクション機能にアクセスして、動作に必要なデスクリプターバインディングを求めたりすることができる。
次の図は、先のフラグメントシェーダーを使った三角形の描画結果である。
-
Vulkan SDK の導入も任意である。ただし macOS と iOS では MoltenVK のセットアップなどが必要かもしれない。 ↩︎
-
その中のひとつに Ash がある。Ash は vulkano の依存クレートのひとつでもあるが、vulkano とくらべて「安全でない」インターフェースを提供する。 ↩︎
-
Vulkan インターフェースはカテゴリーごとに分類され、オブジェクト指向のインターフェースに再構成されている。構造的には Vulkan Hpp に近い。 ↩︎
-
Option
、Result
、Arc
は、それぞれ「ある要素を持たないか、ひとつだけ持つコンテナー」、「(ある処理の結果として)要素かエラー情報のどちらかを持つコンテナー」、「スレッドセーフな参照カウント方式の要素」を示す。 ↩︎ -
具体的には
Buffer::from_iter()
メソッドのひとつ目の引数memory_allocator
と、みっつ目の引数AllocationCreateInfo
による。 ↩︎ -
これは Rust 言語の哲学にも合致するものだ。 ↩︎
-
.wait()
の引数None
は、タイムアウトなしで待機することを示す。 ↩︎ -
shaderc を使うには Vulkan SDK の導入が必要になる。vulkano-shaders の依存クレートである shaderc-sys の機能により、Vulkan SDK を導入するかわりに shaderc をソースコードからビルドすることもできる。 ↩︎
Rust グラフィックス: OpenGL 編
Rust アプリケーションから OpenGL を使いたいときは、おそらく gl クレート(gl-rs)が無難な選択となる。
gl クレートは、Rust 言語用の OpenGL バインダーである。
gl クレートを正常に動かすには OpenGL ランタイムが必要になる。
OpenGL ランタイムはすでに多くの OS でサポートされているため、導入作業は基本的にしなくてよいことが多い[1]。
gl クレートのメソッド呼び出しは、Rust 的には「安全でない」とみなされる。
したがってほとんどの場合、呼び出しの前後で unsafe
ブロックが必要になってしまう。
次のコード片は、指定の明るさで背景を塗りつぶしているところだ。
fn draw_background(brightness: f32) {
unsafe {
gl::ClearColor(brightness, brightness, brightness, 1.0);
gl::Clear(gl::COLOR_BUFFER_BIT);
}
}
メソッドや定数などに使われるシンボルの命名は、元の OpenGL C インターフェースを踏襲している。
したがって、OpenGL を使ったことがある開発者であれば戸惑うことはないだろう。
シンボル名の具体例をいくつか示す。
OpenGL C | gl クレート |
---|---|
glClearColor() |
gl::ClearColor() |
glClear() |
gl::Clear() |
GL_COLOR_BUFFER_BIT |
gl::COLOR_BUFFER_BIT |
glCompileShader() |
gl::CompileShader() |
GL_VERTEX_SHADER |
gl::VERTEX_SHADER |
GL_FRAGMENT_SHADER |
gl::FRAGMENT_SHADER |
gl クレートはあくまで OpenGL ランタイムに対するバインダーであり、3D グラフィックスインターフェース(ラスタライズする機能)だけを提供する。
描画の対象となるサーフェスをウインドウシステムから取得したり、またキーボードやマウスからの入力を取得したりするような機能は含まれない。
そういった機能をアプリケーションに組み込むには、実行環境に依存したコンテキストプロバイダー(あるいはウインドウシェル)が別途必要になる。
コンテキストプロバイダーとして GLFW を使う
GLFW はそのようなコンテキストプロバイダーのひとつである。
GLFW はデスクトップ環境において、ウインドウを作成し、ウインドウイベントや入力イベントをアプリケーションに橋渡しする[2]。
また OpenGL、OpenGL ES に対応したコンテキストとサーフェスを作成する。
GLFW はネイティブ C アプリケーション向けに開発されたものだが、glfw クレートを介して Rust アプリケーションからも使うことができる。
アプリケーションをビルドするために、GLFW ランタイムを導入する必要がある[3][4]。
次のコードは GLFW でウインドウを作成して、背景色を変化させる。
use gl;
use glfw::{Action, Context, Key, SwapInterval, WindowEvent};
fn main() {
let mut glfw = glfw::init(glfw::fail_on_errors).unwrap();
glfw.window_hint(glfw::WindowHint::ContextVersionMajor(3));
glfw.window_hint(glfw::WindowHint::ContextVersionMinor(3));
let (mut window, events) = glfw
.create_window(640, 480, "Hello triangle (hit ESC to exit)", glfw::WindowMode::Windowed)
.expect("Failed to create GLFW window.");
window.make_current();
gl::load_with(|symbol| {
window.get_proc_address(symbol).cast()
});
window.set_key_polling(true);
glfw.set_swap_interval(SwapInterval::Sync(1));
println!("Extension 'GL_ARB_gl_spirv' is {}supported.", if glfw.extension_supported("GL_ARB_gl_spirv") { "" } else { "not " });
while !window.should_close() {
glfw.poll_events();
for (_, event) in glfw::flush_messages(&events) {
match event {
WindowEvent::Key(Key::Escape, _, Action::Press, _) => {
window.set_should_close(true);
},
_ => {},
}
}
let brightness = glfw.get_time().fract() as f32;
draw_background(brightness);
window.swap_buffers();
}
}
全体としてメソッド呼び出しが必要最低限となっていて、コードやリソースの依存関係はスッキリしている。
メソッドや定数などに使われるシンボルの命名は、元の GLFW C インターフェースを踏襲している。
したがって、GLFW を使ったことがある開発者であれば戸惑うことはないだろう[5]。
シンボル名の具体例をいくつか示す。
GLFW C | glfw クレート |
---|---|
glfwInit() |
glfw::init() |
glfwCreateWindow() |
glfw::Glfw::create_window() |
glfwSwapInterval() |
glfw::Glfw::set_swap_interval() |
なお glfwDestroyWindow()
に相当する処理は、window: Window
オブジェクトがスコープから出たときに自動でなされる。
つまり Window
の Drop
トレイトとして実装されている。
トピック: GL コンテキストと拡張性
先のコードの中で、gl クレートの初期化として次のコードを実行している。
gl::load_with(|symbol: &str| {
window.get_proc_address(symbol).cast()
});
このコードでは、gl クレートに定義されている(OpenGL v.4.6 時点の)すべてのメソッドの名前と、GL コンテキスト上のメソッドをひとつひとつ関連付けている
(.get_proc_address()
メソッドは GLFW の glfwGetProcAddress()
メソッドに等しい)。
少しネイティブ層の話になるが、GL コンテキストというのは OpenGL ランタイムに含まれていたメソッド群がメモリに展開されたものと、それらメソッド群が使っているグローバルデータを合わせたものととらえることができる。
OpenGL のように幅広い OS に導入されるランタイムは動的ライブラリ(.dll、.so、.dylib)形式で配備されていて、各メソッドにはあらかじめ名前が付けられているのだが、なぜ OpenGL ではアプリケーション層でメソッドアドレスと名前を関連付けるのか。
その理由は OpenGL 規格の拡張性と関係している。
OpenGL 規格のコア機能のアップデートや拡張機能の定義によって新たなメソッドが登場したときに、そのようなメソッドを使うアプリケーションとそのようなメソッドをサポートしない GL コンテキスト(環境)の組み合わせを、安全に(アプリケーションをクラッシュさせることなく)検知するのに役立つということのようだ。
コンテキストプロバイダーとして glutin を使う
glutin クレートも、OpenGL アプリケーションのためのコンテキストプロバイダーのひとつである。
読みかたは紹介されていないが、たぶん「ぐるとぅん」ではないだろうか(それか「ぐるてん」)。
モバイル環境も含めてマルチプラットフォームで使え、バインドレス(Rust で実装されていてランタイムが必要ない)で、アグノスティックな設計となっている。
glutin クレートは glium クレート[6]とともに開発されたものだが、gl クレートとも組み合わせることができる。
glutin クレートを使ってウインドウサーフェスのコンテキストを作成するときは、やや複雑な手順をたどらなければならない。
順番に見ていこう。
use gl;
use std::ffi::{CStr, CString};
use std::num::NonZeroU32;
use raw_window_handle::HasRawWindowHandle;
use winit::dpi::LogicalSize;
use winit::event::{Event, KeyEvent, WindowEvent};
use winit::event_loop::EventLoopBuilder;
use winit::keyboard::{Key, NamedKey};
use winit::window::WindowBuilder;
use glutin::config::ConfigTemplateBuilder;
use glutin::context::{ContextApi, ContextAttributesBuilder, Version};
use glutin::display::GetGlDisplay;
use glutin::prelude::*;
use glutin::surface::SwapInterval;
use glutin_winit::GlWindow;
fn main() {
let event_loop = EventLoopBuilder::new().build().unwrap();
let window_builder = WindowBuilder::new()
.with_inner_size(LogicalSize::new(640.0, 480.0))
.with_title("Hello triangle (hit ESC to exit)");
let template = ConfigTemplateBuilder::new()
.prefer_hardware_accelerated(Some(true));
let display_builder = glutin_winit::DisplayBuilder::new()
.with_window_builder(Some(window_builder.clone()));
let (mut window, gl_config) = display_builder.build(&event_loop, template, |configs| {
configs
.reduce(|accum, config| {
let transparency_check = config.supports_transparency().unwrap_or(false)
& !accum.supports_transparency().unwrap_or(false);
if transparency_check || config.num_samples() > accum.num_samples() {
config
} else {
accum
}
})
.unwrap()
}).unwrap();
println!("Picked a config with {} samples", gl_config.num_samples());
:
ここまでで、サーフェスとコンテキストの作成および再作成に欠かせない、window: Window
オブジェクトと gl_config: Config
オブジェクトを作成している。
これらのオブジェクトを得るために、winit クレートの WindowBuilder
と glutin クレートの ConfigTemplateBuilder
を glutin-winit クレートの DisplayBuilder
に仲介させている。
glutin クレートは GLFW とは違って、ウインドウを作成したり、ウインドウイベントや入力イベントをアプリケーションに橋渡したりする機能はなく、コンテキストの作成を手伝うことしかできない。
したがって足りない機能は、このように winit クレートで補う必要がある[7]。
初期化の手順は次のようにつづく。
let gl_display = gl_config.display();
let raw_window_handle = window.as_ref().map(|window| window.raw_window_handle());
let context_attributes = ContextAttributesBuilder::new()
.build(raw_window_handle);
let fallback_context_attributes = ContextAttributesBuilder::new()
.with_context_api(ContextApi::Gles(None))
.build(raw_window_handle);
let legacy_context_attributes = ContextAttributesBuilder::new()
.with_context_api(ContextApi::OpenGl(Some(Version::new(2, 1))))
.build(raw_window_handle);
let mut not_current_gl_context = Some(unsafe {
gl_display.create_context(&gl_config, &context_attributes).unwrap_or_else(|_| {
gl_display.create_context(&gl_config, &fallback_context_attributes).unwrap_or_else(|_| {
gl_display.create_context(&gl_config, &legacy_context_attributes)
.expect("failed to create context")
})
})
});
let mut renderer = None;
:
let attrs = window.build_surface_attributes(Default::default());
let gl_surface = unsafe {
gl_display
.create_window_surface(&gl_config, &attrs)
.unwrap()
};
let gl_context = not_current_gl_context
.take()
.unwrap()
.make_current(&gl_surface)
.unwrap();
renderer.get_or_insert_with(|| {
gl::load_with(|symbol| {
let symbol = CString::new(symbol).unwrap();
gl_display.get_proc_address(symbol.as_c_str()).cast()
});
Renderer::new()
});
if let Err(res) = gl_surface
.set_swap_interval(&gl_context, SwapInterval::Wait(NonZeroU32::new(1).unwrap()))
{
eprintln!("Error setting vsync: {res:?}");
}
:
初期化手順の後半では、gl_display
、not_current_gl_context
、gl_surface
、gl_context
の 4 つを作成している。
これでアプリケーションは描画対象と描画手段を得たことになる[8]。
このコード中の renderer: Renderer
オブジェクトはアプリケーションの一部で、gl クレートを使った描画を担っている。
gl_context
と not_current_gl_context
は、どちらも glutin クレートによって導入されるコンテキストであるが、少々独特でややこしいかもしれない。
前者の gl_context: PossiblyCurrentContext
オブジェクトは「カレントのコンテキスト」をあらわす。
カレントのコンテキストは、サーフェスをリサイズしたりスワップするのに必要となる。
これに対して後者の not_current_gl_context: NotCurrentContext
オブジェクトは、「カレントでないコンテキスト」をあらわす。
初期化手順で示したとおり、glutin クレートを使って最初に得られるのはこのコンテキストである。
カレントでないコンテキストは .make_current()
メソッドを備えており、.make_current()
に描画先サーフェスを指定することで、カレントのコンテキスト gl_context
を得ることができる。
またカレントのコンテキストは .make_not_current()
メソッドを備えており、アプリケーションがサスペンドするときはこのメソッドでカレントでないコンテキスト not_current_gl_context
を得ることができる。
それによって別のアプリケーションがコンテキストを使うのを妨げないようにする。
次の図は、頂点シェーダーとフラグメントシェーダーを使った三角形の描画結果である。
-
Windows 環境であれば、エクスプローラーを使って
C:\Windows\system32\opengl32.dll
を見つけることができるだろう。
これが OpenGL ランタイムだ。 ↩︎ -
モバイル環境には対応していない。 ↩︎
-
GLFW の公式サイト には Vulkan をサポートすることが明記されているが、glfw クレートはこの機能をサポートしていないようである。 ↩︎
-
ビルド済み GLFW ランタイムは、 https://www.glfw.org/download.html から入手する。Windows 11 で GLFW を利用する Rust アプリケーションをビルドするには、glfw-3.3.9.bin.WIN64.zip をダウンロードして、アーカイブの中の \glfw-3.3.9.bin.WIN64\lib-vc2019\glfw3.lib をアプリケーションのプロジェクトフォルダーにコピーする。またクレート導入時に
--no-default-features
オプションが必要。 ↩︎ -
glfw クレートでは GLFW のメソッド群がカテゴリーごとに分類され、オブジェクト指向のインターフェースに再構成されている。 ↩︎
-
glium クレート は、Rust アプリケーションのために OpenGL インターフェースを提供する、安全なバインダーとして開発されていた。
読みかたは紹介されていないが、たぶん「ぐりうむ」ではないだろうか。
リポジトリは現在もコミュニティーによって維持されているが、機能開発は長らく止まっている。
くわしくは Glium post-mortem を参照のこと。 ↩︎ -
glutin v.0.29 までは依存クレートに winit が含まれており、glutin クレート自体をウインドウシェルのように使うことができたが、v.0.30 からはデカップリングされている。
glutin クレートを使ったサンプルコードや依存クレートに、glutin v.0.29 をバージョン指定しているものが多いのは、この複雑さ(と破壊的変更)が要因であろう。 ↩︎ -
モバイル環境におけるサスペンドからの復帰と、ウインドウリサイズにともなうサーフェスの再作成のため、ここに引用したコード片の一部はイベントループの中で実行される。 ↩︎
Rust グラフィックス: glow 編
glow クレートは、Rust 言語用の OpenGL バインダーである。
読みかたは紹介されていないが、たぶん「ぐろう」ではないだろうか[1]。
glow クレートは gl クレートの代替であり本質的には同じことをする。
インターフェースが「安全でない」のも同じだ。
glow クレートを正常に動かすには OpenGL ランタイムが必要になる。
この点も gl クレートと変わりはない。
しかし crates.io でダウンロード数を比べてみると、glow クレートの人気は圧倒的である[2]。
この理由を探るべく、glow クレートが gl クレートよりも優れている点を深堀りしてみよう。
規格に由来する基本型の排除
OpenGL メソッドのひとつ glShaderSource()
の定義をみてみよう。
void glShaderSource(
[in] GLuint shader,
[in] GLsizei count,
[in] const GLchar **string,
[in] const GLint *length
);
このように OpenGL 規格では、メソッドを定義するために GLuint
や GLsizei
といった基本型を使っている。
このほかにも GLenum
といったものもある。
C ツールチェーンや開発環境によって定義がゆらぐことがないよう、これらの基本型は規格として定められているのだ[3]。
gl クレートは、これらの GL 基本型を Rust 上に移植して、OpenGL の各メソッドを Rust のコードとして定義するときでも GL 基本型を使っている。
pub unsafe fn ShaderSource(shader: types::GLuint, count: types::GLsizei, string: *const *const types::GLchar, length: *const types::GLint) -> ()
これに対して glow クレートでは、GL 基本型を Rust 上に移植せず、OpenGL の各メソッドを Rust のコードとして定義するときは代わりに Rust 基本型を使っている。
unsafe fn shader_source(&self, shader: Self::Shader, source: &str)
このメソッドにおいては定義がマイナーチェンジされていて、シェーダーコード(文字列)を分割して渡せなくなっているものの、パラメーターが 4 つからふたつに整理され、少し直感的なメソッドに改善されている。
どちらも納得のいく定義であり違和感は少ないのだが、Rust アプリケーションにおけるコードの可読性という観点では顕著に差がでてしまう。
実際に、シェーダーコードをコンパイルする create_shader()
メソッドを書きくらべてみよう[4]。
OpenGL メソッド glCreateShader()
、glShaderSource()
、glCompileShader()
を順に実行して、コンパイルエラーを glGetShaderInfoLog()
で確認する手順となっている。
// gl クレート対応版
unsafe fn create_shader(shader_type: GLenum, source: &[u8]) -> Result<GLuint, String> {
let shader = gl::CreateShader(shader_type);
gl::ShaderSource(shader, 1, [source.as_ptr().cast()].as_ptr(), std::ptr::null());
gl::CompileShader(shader);
let error: CString = create_string(100);
gl::GetShaderInfoLog(shader, 100, std::ptr::null_mut(), error.as_ptr() as *mut GLchar);
if !error.is_empty() {
return Err(error.to_string_lossy().into_owned())
}
Ok(shader)
}
// glow クレート対応版
unsafe fn create_shader(gl: &gl::Context, shader_type: u32, source: &str) -> Result<Shader, String> {
let shader = gl.create_shader(shader_type).unwrap();
gl.shader_source(shader, source);
gl.compile_shader(shader);
if !gl.get_shader_compile_status(shader) {
let error = gl.get_shader_info_log(shader);
return Err(error)
}
Ok(shader)
}
gl クレート対応版のコードで目をひくのは、.as_ptr()
、std::ptr::null()
、as *mut GLchar
といった型の整合をとるためのコードの煩雑さだ。
gl::ShaderSource()
の箇所では、3 つめのパラメーターに文字列片(ポインター)の配列のアドレスを渡すために、[source.as_ptr().cast()].as_ptr()
というコードになっている。
またシェーダーコードを示す source: &[u8]
オブジェクトが &str
となっていないのは、3 つめのパラメーターにヌル終端された文字列を渡すためのテクニックを使うためだ[5]。
同じ処理が、glow クレート対応版のコードでは source: &str
をパラメーターに渡すだけですむ。
型の整合をとるためのそういったコードが、表面上は排除されている。
gl クレートの gl::GetShaderInfoLog()
周辺のコードも顕著な例だ。
これは OpenGL のメソッド glGetShaderInfoLog()
を呼び出すもので、シェーダーをコンパイルしたときのエラーメッセージを受け取るメソッドだ。
void glGetShaderInfoLog(
[in] GLuint shader,
[in] GLsizei maxLength,
[out] GLsizei *length,
[out] GLchar *infoLog
);
このような出力パラメーターを備えたメソッドは、そのまま移植してしまうと呼び出すのが面倒なものになりがちだ。
Rust アプリケーションから呼び出すことを考えたとき、どうしても冗長なコードが必要になってしまうのだ。
glow クレートはこういった面倒な部分を排除して、開発者にやさしいインターフェースを提供しているといえるだろう。
トピック: メソッドから文字列を受け取る方法
gl クレート対応版で示したコードでは、create_string()
というメソッドを実装する必要があった。
このメソッドは、OpenGL のメソッドの中で内容を更新してもらうために、ある程度の長さを持った文字列用バッファを確保する。
次にコードを示す。
unsafe fn create_string(len: usize) -> CString {
let mut buffer = Vec::with_capacity(len);
buffer.extend(std::iter::repeat(b' ').take(len));
CString::from_vec_unchecked(buffer)
}
トピック: OpenGL バインダーの裏側
OpenGL には 800 以上の拡張、3000 以上のメソッド、15000 以上の定数が規定されている。
この膨大な定義を管理するため、規格団体である Khronos Group はこれらの定義をまとめた XML ファイルを公開している。
gl クレートでは、これらの XML ファイルを gl_generator という Rust 製ツールでテキスト処理することで、Rust ソースコードを自動生成している。
glow クレートはというと、こちらも phosphorus という Rust 製ツールでテキスト処理することでソースコードを生成しているが、クレートを利用する開発者のためにひと手間加えて、人力でラッパーを実装しているようだ。
-
由来は "GL on Whatever"。OpenGL と OpenGL ES と WebGL を扱える、ということのようだ。 ↩︎
-
クレートダウンロード遷移一覧を参照のこと。 ↩︎
-
gl クレートと glow クレートでは、OpenGL インターフェースのスコープが異なるので気をつけよう。gl クレートでは
gl
名前空間のメソッドとして実装されているが、glow クレートではglow::Context
オブジェクトのメソッドとして実装されている。 ↩︎ -
Rust の文字列はヌル終端されないが、シェーダーコード文字列の末尾に
b"\0"
を付けておくことで、ヌル終端された文字列をデータ化することができる。こうすることでCString
オブジェクトの作成をひとつ減らすことができるが、副作用としてその文字列データは[u8]
型になってしまう。 ↩︎
Rust グラフィックス: gfx/gfx-hal 補足
wgpu クレートは、gfx-rs チームによって開発が進められている。
gfx-rs チームは、WebGPU の登場以前から同じような思想をもつ gfx というクレートを開発していて、その成果の多くが wgpu クレートに取り込まれたようだ[1]。
wgpu クレートと wgpu-hal クレートはそれぞれ gfx クレートと gfx-hal クレートの後継であるし、naga クレートは gfx クレートに含まれる SPIRV-Cross をピュアな Rust で再実装した成果である。
gfx-hal クレートは gfx クレートの下位レイヤーにあたる薄いラッパーだ。
Vulkan や Metal といった次世代グラフィックスインターフェースをバックエンドとした、「安全でない」がコンテキストレスで透過的なインターフェースを提供する[2]。
gfx-hal クレートを正常に動かすためには、開発者が適切なグラフィックスバックエンドを選択してアプリケーションに組み込む必要があった。
そのようなバックエンドには gfx-backend-gl、gfx-backend-dx11、gfx-backend-dx12、gfx-backend-metal、gfx-backend-vulkan がある。
wgpu クレートが現在のようにマルチバックエンドで動作するのは、gfx-hal クレートの成果が大きい。
なお gfx クレートと gfx-hal クレートは、現在どちらもメンテナンスモードである。
これらのクレートに依存しているアプリケーションは、wgpu クレートへ移行するのが得策かもしれない[3]。
リンク | ||
---|---|---|
2015 年 2 月 | gfx v0.1 最初のリリース | |
2016 年 2 月 | Vulkan 規格 v1.0 の公開 | https://registry.khronos.org/vulkan/specs/1.0/html/vkspec.html |
2018 年 1 月 | gfx は pre-ll に、gfx ll は gfx(通称 gfx-hal)に | https://gfx-rs.github.io/2017/07/24/low-level.html |
2018 年 12 月 | gfx-hal v0.1 最初のリリース | |
2019 年 3 月 | wgpu v0.2 最初のリリース | |
2019 年 2 月 | gfx pre-ll は v0.18 をもってメンテナンスモードへ | |
2021 年 5 月 | WebGPU 規格、初めてのワーキングドラフト公開 | https://www.w3.org/TR/2021/WD-webgpu-20210518/ |
2021 年 6 月 | gfx-hal は v0.9 をもってメンテナンスモードへ | https://gfx-rs.github.io/2021/07/16/release-0.9-future.html |
2023 年 10 月 | wgpu v0.18 が OpenGL 3.3+ on Windows をサポート |
-
wgpu の GitHub リポジトリが https://github.com/gfx-rs/wgpu にあるのはそのためだ。 ↩︎
-
gfx クレートと gfx-hal クレートのリポジトリは、どちらも https://github.com/gfx-rs/gfx である。開発の過程で前者は pre-ll(ぷれ えるえる、ll は Lower-level の略)と呼ばれるようになり、単に "gfx" といえば後者ということになったようだ。 ↩︎
-
インターフェースに互換性はないため、移行には工数がかかってしまう。 ↩︎
Rust グラフィックス: pixels 編
pixels クレートは、2D 描画のためにセットアップされたレンダリングパイプラインである。
主な役割は次の 2 つ。
- ウインドウサーフェスから、フレームバッファーとレンダリングパイプラインを作成する
- フレームバッファーの読み書き手段を提供する
pixels クレートのキャッチコピーは "A tiny hardware-accelerated pixel frame buffer" となっている。
クレートの中心となる Pixels
オブジェクトは、.frame_mut()
メソッドを介して、フレームバッファーのメモリ配列 &mut [u8]
を返すことができる。
アプリケーションがこの配列の中身を書き換えるだけで、あとはおまかせでウインドウサーフェスを更新することができる。
メモリ配列の最初の 4 バイトは、ウインドウ座標 (0, 0) にあるピクセルの RGBA 値をあらわしている。
この 4 バイトを [0xff, 0xff, 0xff, 0xff]
で上書きすれば、白 100% の点がウインドウの左上隅に反映される。
座標 (1, 0) のピクセルはその次の 4 バイトに対応している。
フレームバッファーの横幅が 320 ピクセルであれば、座標 (0, 1) のピクセルは先頭から 1280 バイト目からはじまる 4 バイトに対応している。
このような具合で、ほとんど知識が必要ないのが特徴だ。
次のコードスニペットは、ウインドウに置いた矩形領域の内側と外側を塗り分ける。
fn draw(&self, frame: &mut [u8]) {
for (i, pixel) in frame.chunks_exact_mut(4).enumerate() {
let x = (i % WIDTH as usize) as i16;
let y = (i / WIDTH as usize) as i16;
let inside_the_box = x >= self.box_x
&& x < self.box_x + BOX_SIZE
&& y >= self.box_y
&& y < self.box_y + BOX_SIZE;
let rgba = if inside_the_box {
[0x5e, 0x48, 0xe8, 0xff]
} else {
[0x48, 0xb2, 0xe8, 0xff]
};
pixel.copy_from_slice(&rgba);
}
}
このコードスニペット内の for ループは、フレームバッファーの総ピクセル数のぶん繰り返される。
画像ファイルから読み出したビットマップがあれば、同様の方法で、ウインドウの中に画像を配置することもできる。
pixels クレートは、それ単体で一切の描画手段を提供しない。
一般的に使われるフィル&ストローク方式のインターフェースなども提供しない。
そういった機能が必要であれば、追加のクレートが必要になる。
Rust のラスタライザーにはいくつかの選択肢がある。
たとえば tiny-skia クレートは、フィル&ストローク方式による図形をラスタライズすることができる。
次のスクショは、その使用例だ。
バックエンドが GPU 対応している
pixels クレートのもうひとつの特徴は、バックエンドに wgpu クレートを採用している点だ。
Pixels
オブジェクトの .render_with()
メソッドは、wgpu::CommandBuffer
と wgpu::TextureView
を受け取るメソッドを指定することができる。
つまり、任意のレンダーパスを追加できるということだ。
次のスクショは、追加のレンダーパスで egui クレートを使って、フレームバッファーの手前にウインドウをオーバーレイさせる例だ。
.frame_mut()
メソッドを介してフレームバッファーを書きかえる処理は、CPU で行われるため、この部分は GPU による並列化の恩恵を受けられない。
面積に比例した CPU パワーが求められるため、アプリケーション用途との親和性を見極めたうえで採用しなければならない。
少なくともフレームバッファー全体を、頻繁かつリアルタイムに書きかえるような用途には向かない。
ゲームアプリケーションで想定するなら、高解像度のものや、背景面の重ねあわせを多用するものは相性がわるい。
クラシックゲームなど、塗りつぶす面積が限られるものは、相性がいい。
また、ソフトウェアレンダラーやソルバーのような重たい処理を実行しているあいだに、アダプティブなプレビューを表示するようなシナリオなら、比較的相性がいいかもしれない。
Rust グラフィックス: egui 編
egui クレートは、Rust で実装された補助的な GUI システムである。
読みかたは「いー ぐーうぃ」。
おもな役割は次の 2 つ。
- ラベル、ボタン、テキストボックスなどのウィジェットを並べて、バックエンド向けの頂点配列を作成する
- 入力イベントを受け付けて、ウィジェットの状態を変化させる
ウィジェットの種類は次のとおりで、およそ一般的なものはそろっているようだ。
独自のウィジェットも作成できる。
egui クレートは、ウェブを含めたマルチプラットフォームで動くよう設計されている。
ウィジェットのレンダリングは描画バックエンドとなるクレートに移譲しているので、アプリケーションの環境にあわせて開発者が選択する必要がある。
描画バックエンドクレートには egui-glow クレート、egui-ash クレート、egui-vulkano クレート、egui-wgpu クレート、egui-web クレートなどがある。
egui クレートを使いはじめるには、まずアプリケーションを起動するときにegui::Context::default()
メソッドで、egui::Content
オブジェクトを作成する。
このオブジェクトは、egui インターフェースの状態を管理している。
このオブジェクトの .tessellate()
メソッドは頂点配列を出力するので、これを描画バックエンドに渡して GUI 全体を描画する。
描画バックエンドを自前で実装することもできる。
egui::Ui
オブジェクトは、ウィジェットを配置するためのものだ。
ウィジェットの配置先となるものには、egui::SidePanel
オブジェクト、egui::TopBottomPanel
オブジェクト、egui::CentralPanel
オブジェクト、egui::Window
オブジェクトなどがある。
これらのオブジェクトは、ui: egui::Ui
をパラメーターとするハンドラーを渡すことができる。
開発者はそのハンドラーの中で、GUI を構築していく。
egui::CentralPanel::default().show(ctx, |ui| {
// ここに ui: egui::Ui を使った処理を書く
});
入力イベントのハンドリングについても、同じようにバインダーが存在する。
たとえば winit クレートを使う Rust アプリケーションでは、egui-winit クレートを選択することができる。
このクレートは、winit::event::WindowEvent
オブジェクトを egui::Content
オブジェクトに通知するのを仲介する。
イミディエイトモード GUI
egui クレートのインターフェースは、イミディエイトモードに分類される。
Dear ImGui[1] に着想を得ながら、より Rust フレンドリーとなるよう再設計したものとなっている。
イミディエイトモードを採用する利点はいくつかあるが、アプリケーションコードがシンプルに保てて、開発者にとって扱いやすいのが大きい。
いくつかのウィジェットを使用するときでも、アプリケーションコードが実行される順番を乱さないからだ。
フローを見失うことなく、コールバックがいつ発火するかや、そのタイミングでのスタックの状態などを慎重に精査しなくてすむ。
次のコードスニペットは、ドキュメントに載っている使用例だ。
これはメインループのなかで、毎回呼び出される。
ui.heading("My egui Application");
ui.horizontal(|ui| {
ui.label("Your name: ");
ui.text_edit_singleline(&mut name);
});
ui.add(egui::Slider::new(&mut age, 0..=120).text("age"));
if ui.button("Click each year").clicked() {
age += 1;
}
ui.label(format!("Hello '{name}', age {age}"));
ui.image(egui::include_image!("ferris.png"));
ウィジェットをひとつ配置するのに、命令をひとつ呼び出すのが基本となる。
これでパネルやウインドウの中に、ウィジェットが次々と配置されていく。
準備として、それぞれのウィジェットを new したり、ウインドウに append したり、終了するときにあと片付けする必要がない。
ユーザー入力に反応するコントロールウィジェットについても、コールバックを設定する必要がない。
ui.button()
のあとの .clicked()
メソッドは、ボタンの中でクリックイベントが発火したかどうかを返す。
これを使って、if ブロックの中にクリックイベントをハンドルするコードを直接書ける。
イミディエイトモードでないインターフェース(保持モード、英語では Retained mode)と比べると、ウィジェットのレイアウトにはかなり制約がある。
そのため、egui クレートをアプリケーションの GUI 基盤として使うことはむずかしい場合がある。
アプリケーションの機能に付随した、補助的な GUI として、またはデバッグ用途の GUI としてであれば、充分使用できる。
-
Dear ImGui ( https://github.com/ocornut/imgui )は C++ で実装されている。同じくイミディエイトモード GUI として、よく知られる。 ↩︎
Rust グラフィックス: iced 編
iced クレートは、Rust で実装された GUI システムである。
読みかたは紹介されていないが、たぶん「あいすと」ではないだろうか。
おもな役割は次の 2 つ。
- ラベル、ボタン、テキストボックスなどのウィジェットを並べて、バックエンド向けの頂点配列を作成する
- 入力イベントを受け付けて、ウィジェットの状態を変化させる
iced クレートは、ウェブを含めたマルチプラットフォームで動くよう設計されている。
ウィジェットのレンダリングは描画バックエンドとなるクレートに移譲しているので、アプリケーションの環境にあわせて開発者が選択する必要がある。
描画バックエンドクレートには iced-web クレート、iced-wgpuクレート、iced-glowクレート、iced-native クレートなどがある。
またウインドウバックエンドには iced-winit クレート、iced-glutin クレートなどがある。
アプリケーションのふるまいは、.new()
、.title()
、.update()
、.view()
という 4 つのメソッドを備えた、iced::Sandbox
トレイトか iced::Application
トレイトとして実装することができる。
これらのトレイトには描画バックエンドとウインドウバックエンドへのバインド処理が含まれているので[1]、あとは .run()
メソッドを呼び出すだけでアプリケーションウインドウが作成され、各種イベントがハンドリングされる。
use iced::widget::{button, column, text};
use iced::{Alignment, Element, Sandbox, Settings};
pub fn main() -> iced::Result {
let mut settings = Settings::default();
settings.window.size = (480, 320);
Counter::run(settings)
}
impl Sandbox for Counter {
fn new() -> Self {
Self { value: 0 }
}
fn title(&self) -> String {
String::from("iced tutorials")
}
fn update(&mut self, message: Message) {
:
}
fn view(&self) -> Element<Message> {
:
}
}
iced クレートは、Elm アーキテクチャーを参考に開発されたものだ。
iced クレートを使って GUI を実装するときは、アプリケーションを 4 つの部品に分解することが求められる。
- State: アプリケーションの状態
- Message: ユーザーインタラクションや発生したイベントの結果
- View logic: State を一連のウィジェットとして表現するもので、Message を発生させるもの
- Update logic: Message の処理方法を示すもので、State を変化させるもの
単純な例として、カウンターを増減させるだけのアプリケーション Counter を考える。
このアプリケーションにとって、カウンターの値が State にあたる。
struct Counter {
value: i32,
}
カウンターの値はひとつずつ増やすか、ひとつずつ減らすことしかできないものとするなら、Message は次の 2 つが考えられる。
#[derive(Debug, Clone, Copy)]
enum Message {
IncrementPressed,
DecrementPressed,
}
アプリケーションの GUI 表現は、カウンターの値を示すひとつのラベルと、2 つのボタンとするのが自然だ。
そうするには iced::widget
のメソッドをいくつか呼び出す。
fn view(&self) -> Element<Message> {
column![
button("Increment").width(120).on_press(Message::IncrementPressed),
text(self.value).size(50),
button("Decrement").width(120).on_press(Message::DecrementPressed)
]
.padding(20)
.width(480)
.align_items(Alignment::Center)
.into()
}
iced::widget::column
マクロに指定している iced::widget::button()
と iced::widget::text
は、それぞれボタンウィジェットとテキストラベルウィジェットを表示する。
ひとつ目のボタンのラベルは "Increment" で、幅は 120 ピクセルで、クリックされるたびに Message::IncrementPressed
メッセージを生成する。
この iced::widget::column
マクロは、配列内のウィジェットを垂直にならべるカラムウィジェットを表示する。
それぞれのウィジェットは 20 ピクセル間隔で、中央揃えされていて、ウィジェット全体の幅は 480 ピクセルとなる。
仕上げとして、Message が State にどのような影響を与えるかについて、Update logic として記述する。
これは Message が生成されるたびに呼ばれる。
fn update(&mut self, message: Message) {
match message {
Message::IncrementPressed => {
self.value += 1;
}
Message::DecrementPressed => {
self.value -= 1;
}
}
}
この例で見たとおり、.view()
メソッドでウィジェットを表示する処理では iced クレートのメソッドを明示的に呼び出すことになる。
メソッドの呼び出しがウィジェットの描画に直結しているため、保持モードのインターフェースよりは直感的で使いやすいと感じられる。
残る State、Message、Update logic についても、すべて開発者定義のものを使うことができ、とても柔軟性がある。
iced::Sandbox
トレイトや iced::Application
トレイトを使ったときは、イベントループは iced クレートに管理されることになる。
もし既存のグラフィカルアプリケーションに iced クレートを組み込みたいなら、これらのトレイトを使わずに描画バックエンド(Renderer
トレイト)を自作することもできる[2]。
-
iced クレートを cargo add すると、デフォルトでは依存クレートとして wgpu、iced-wgpu、winit、iced-winit がインストールされる。 ↩︎
-
iced クレートのリポジトリにある、integration example( https://github.com/iced-rs/iced/tree/0.10/examples/integration )を参考にするのが近道だ。 ↩︎