Peridot SpriteAtlas Visualizer開発知見リスト
飽きるまで作ります(とはいったけどこれできないとエンジン本体もろくに進捗なくなるので多分ある程度使えるくらいまではやる
https://github.com/Pctg-x8/peridot-sprite-atlas-visualizer の作りながら貯めた知見をメモがてら公開しています。
Rustをフルに使った一般的なWindows向け[1]GUIアプリケーションの想定で、以下の知見を貯めることを目標としています。
- Rustにおけるごく一般的なGUIアプリケーションの開発知見
- Viewアーキテクチャ(MVP, MVVMなど)関連
- borrowckやlifetimeとどう折り合いをつけるか
- Rust+WinRT+Windows App SDKの開発知見
- 周辺ライブラリ(NuGet)の使い方
- その他C++/WinRTでやってることと同等のことをどう再現できるか
基本的には適当に書き散らしてますが、ある程度溜まったら個別に記事になるかも
-
将来的にはマルチプラットフォームにしないとですが、いきなりそれやっても難しすぎるだけなので一旦Windowsにプラットフォームを絞っています。 ↩︎
Windows App SDK初期化周り
基本的にはこの手順がそのまま適用できます。
ただしここに出てくるBootstrap APIはNuGet経由で取得する必要があるため、cargoとNuGetをうまく併用する環境を作る必要があります。
注意事項(Rust/C++共通)としては、MddBootstrapInitialize2
にわたすSDKバージョンはマイナーバージョン含めて完全一致である必要があります。
これは、メジャーバージョンがあっているからといって雑にマイナーバージョンに0を指定しても「1.x」でランタイムが探されるわけではなく「1.0」のみが探されることを意味します。システムにバージョン1.0のランタイムが入っていない場合はエラーとなりアプリケーションが起動できなくなります。
cargo + NuGet
cargo(Rustプロジェクト)でNuGetを使う場合はNuGet CLIを直接使用する方法をとります。
NuGetでは言語/IDEに依存しないパッケージ宣言ファイルとして独自のpackages.config
があり、これを使って管理していくことになります。
また、既定では妙な位置($Env:APPDATA
以下)にパッケージが配置されるためbuild.rs
などから手を加えるのが大変です。そこで、パッケージをrestoreする際に配置先ディレクトリをRustプロジェクト内に指定するのがおすすめです。
$ nuget restore ./packages.config -PackagesDirectory ./.nuget
この.nuget
フォルダはgitにcommitする必要はないので.gitignore
に書いておきます。
RustからWindows APIを使う
RustからWindowsの各種APIを使う場合はwindows
クレートを使用します。これにはWin32APIだけでなくWinRTのインターフェイスも入っているため基本的にはすべてのユースケースでこれを使うことができます。
ただし全てのAPIを有効にした状態だとコンパイルに時間がかかりすぎるためかfeatureを指定してあげないとAPIの定義が出てこないようになっています。あらかじめ使いたいAPIの関数名/COMインターフェイス名がわかっている場合はFeatures Searchのページを使うことでシンボル名からの逆引きが可能です。
ちなみに、一部のAPIで登場する型(Vector2
など)はfeatureで有効にする形ではなく別のクレートとして提供されています。
-
windows-numerics
:Vector2
Vector3
など -
windows-collections
:IIterable
など
Windows.UI.Compositionを使ってリッチな見た目を得る
Windows App SDKを使っているからといえ、Rustでウィンドウを出す手順はWin32のAPIを使った場合と同じです。
ウィンドウの中身の描画は、GDIやDirect2D直接ではなくComposition APIを使います。これは単なるビットマップの合成APIですが、サーフェイスの背面の描画内容を取得してエフェクトをかけることができる機能などもあり、よくあるモダンなすりガラス表現を取り入れたりしてリッチな見た目のアプリケーションを作ることができます。
(Windows UWPの内部で使われているAPIが露出しているのでそれを直接使う形になります。)
Composition APIはそれ単体で初期化することができ、以外にも使い始めることはそこまで難しくないAPIです。ただしWinRTのAPIなので前提としてDispatcherQueueController
が初期化されている必要があります。
let _dispatcher_queue_controller = unsafe {
CreateDispatcherQueueController(DispatcherQueueOptions {
dwSize: core::mem::size_of::<DispatcherQueueOptions>() as _,
threadType: DQTYPE_THREAD_CURRENT,
apartmentType: DQTAT_COM_ASTA,
})
.expect("Failed to create dispatcher queue")
};
let compositor = Compositor::new().expect("Failed to create ui compositor");
Composition API自体はそれ自体で描画を完結させることもできますが、これ単体では単純なビットマップ合成しかできない(丸角描画やベクター画像のラスタライズみたいな機能はない)ためそうした若干高度な描画機能を使うためにDirect2Dを併用します。ここで作るDirect2DデバイスはDirect3D(DXGI)がバッキングにいる必要があります。
let mut d3d11_device = core::mem::MaybeUninit::uninit();
let mut feature_level = core::mem::MaybeUninit::uninit();
let mut d3d11_imm_context = core::mem::MaybeUninit::uninit();
unsafe {
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE(core::ptr::null_mut()),
D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_DEBUG,
None,
D3D11_SDK_VERSION,
Some(d3d11_device.as_mut_ptr()),
Some(feature_level.as_mut_ptr()),
Some(d3d11_imm_context.as_mut_ptr()),
)
.expect("Failed to create D3D11 Device");
}
let d3d11_device = unsafe {
d3d11_device
.assume_init()
.expect("no d3d11 device provided?")
};
let feature_level = unsafe { feature_level.assume_init() };
let d3d11_imm_context = unsafe {
d3d11_imm_context
.assume_init()
.expect("no d3d11 imm context provided?")
};
println!("d3d11 feature level = {feature_level:?}");
let d2d1_factory: ID2D1Factory1 = unsafe {
D2D1CreateFactory(
D2D1_FACTORY_TYPE_MULTI_THREADED,
Some(&D2D1_FACTORY_OPTIONS {
debugLevel: D2D1_DEBUG_LEVEL_WARNING,
}),
)
.expect("Failed to create d2d1 factory")
};
let d2d1_device = unsafe {
d2d1_factory
.CreateDevice(&d3d11_device.cast::<IDXGIDevice>().expect("no dxgi device?"))
.expect("Failed to create d2d1 device")
};
// ...
let compositor_interop = compositor
.cast::<ICompositorInterop>()
.expect("no compositor interop support");
let compositor_desktop_interop = compositor
.cast::<ICompositorDesktopInterop>()
.expect("no compositor desktop interop");
let composition_2d_graphics_device = unsafe {
compositor_interop
.CreateGraphicsDevice(&d2d1_device)
.expect("Failed to create composition 2d graphics device")
};
ICompositorDesktopInterop
は通常のデスクトップ上で合成するために必要で、ここから生成できるCompositionTarget
にHWND
やCompositionVisual
を紐づけることでビジュアルツリーをウィンドウの内容として合成するようになります。
ポインター入力処理とヒットテスト
Win32APIが提供するポインター(マウス)入力はウィンドウという単位を前提としています。昔はウィンドウの上に乗せる各種コンポーネントもまたウィンドウ(子ウィンドウ)だったのでこの前提で良かったのですが、今回はComposition APIで各種コンポーネントの表示内容を合成しています。Composition APIはその名の通り合成しか行わないのでポインター入力およびポインターとのヒットテストの機構は別途作る必要があります。
HitTest Tree
今回のアプリケーションではヒットテストためにHitTest Treeを作りました。3DCGに明るい人だとBounding Volume Hierarchy(BVH)とか聞いたことがあるかと思いますが、あれに近いです。ただし二分木ではありません。要素を囲うAABBが葉としてあり、複数のそれを内包するAABBがその上位の枝としてあり...といった感じで木構造をつくっているところは同じです。
この構造のモデルはComposition APIのビジュアルツリーで、可能な限りビジュアルツリーで表現できるAABBの位置情報と親子関係を持てるようになっています。
HitTest Treeのオブジェクト構成
HitTest Treeは以下のオブジェクトからなっています。
-
HitTestTreeActionHandler
: HitTest Treeのノードに紐づけるイベントハンドラ(後述)のインターフェイス -
HitTestTreeData
: HitTest Treeの各ノードの実際の内容 -
HitTestTreeRef
: HitTest Treeの各ノードへの参照ID -
HitTestTreeManager
: HitTest Tree全体を管理するマネージャーオブジェクト
ここで、HitTestTreeRef
は実は単なるusize
のnewtypeになっています。つまりHitTestTreeRef
は実態を持ちません。
HitTest Treeの全体像はいわゆるArenaのような形をしており、実態のメモリを一つのところで集中管理することでHitTest Treeにまつわるメモリ周りの取り回しをしやすい形にしています。
HitTestTreeData
自体を直接持つ形でも良いですが、そうするとこれはこれ同士で互いに参照するのと、View/コンポーネントもこれを所有する必要がある(サイズ/位置変更時に中身のパラメータを変更する必要があるため)ため参照が複数になる、つまりRc
かArc
で囲う必要があるのと、先にも書いた通り共有参照を持ったまま任意のタイミングで読み書きすることが頻繁に起こるのでRefCell
Mutex
RwLock
でも囲う必要があります。
後者の動的に可変参照を得るものについてはメソッドから関連するデータを返す際に強めの制約が生まれてしまうのと、とくにRefCell
は複数箇所からborrow_mut
したタイミングで問答無用でpanicするため[1]常にpanicの可能性に怯えながら過ごすことになります。
また、Rc
もArc
も参照カウントをやり取りするコストが少なからず積まれます。
これらの制約/コスト増くらい受け入れても良いといえば良いですが、受け入れなくても良い方法もあるのであればそちらに倒すほうがよいでしょう。
adjustment_factor
特徴的なパラメータとしてadjustment_factor
があります。これはComposition APIのRelativeOffsetAdjustment
RelativeSizeAdjustment
にそのままぴったり対応するパラメータで、親矩形内における相対的な調整ファクタ値(0.0~1.0)を持っています。このパラメータを使った実際の座標の計算は以下のようになります(Composition APIが内部で使っている計算式を模倣したものです)。
effective_left = left + parent_width * left_adjustment_factor;
effective_top = top + parent_height * top_adjustment_factor;
effective_width = width + parent_width * width_adjustment_factor;
effective_height = height + parent_height * height_adjustment_factor;
これらのパラメータにより、例えば親の矩形とぴったり一致する矩形を作る場合はwidth_adjustment_factor
とheight_adjustment_factor
を1にするだけでよく、また右端に張り付くような矩形もleft_adjustment_factor
を1にしたうえでleft
に負数を指定することで指定できます。従来よく行っていた「ウィンドウサイズ変更に追従して毎回コンポーネントの座標を計算し直す」手間をComposition API/HitTest Treeがかわりに行うことでアプリケーションコードを簡素にすることができます[2]。
イベントハンドラ(Action Handler)
ヒットテストに成功し、実際にポインター制御モジュール(後述)からイベントが生成されたときにはヒット対象のHitTest Treeに紐づいたイベントハンドラ(HitTestTreeActionHandler
)が呼ばれる仕組みになっています。
このイベントハンドラは不意なメモリリークを避けるため、HitTest Treeでは弱参照で持ち基本的にはView/コンポーネント側がオーナーとなるようにしています。
イベントハンドラは任意のContext
型の値をひとつ受け取ることができるような仕組みにしています。アプリケーション側ではここにアプリケーションのグローバルステート(AppState
)への可変参照を持たせて、入力イベントに応じて内部モデルへのアクションを実行できるようにしています。
ポインター状態トラッキング
HitTest TreeでComposition API上の要素に対してヒットテストを行うことができるようになりましたが、そのヒットがどういった意味を持つのかはHitTest Treeの外側で判定してあげる必要があります(HitTest Treeはヒットテストにだけ関心があり、例えばポインタージェスチャーといった部分には関心がありません)。
今回のアプリケーションではそのあたりを処理するサブモジュールとしてPointerInputManager
を準備しています。これはウィンドウごとに1つだけ持つ状態オブジェクトで、ウィンドウ上で発生したすべてのポインタイベントをこのオブジェクトに流し込むことで内部状態を変形させながらジェスチャーなどの判定を行う形にしています。
PointerInputManager
は今のところポインターのhover、基本的なdown/up/moveイベントとクリックイベントの判定と生成を行っています。仕様としてはほぼWebブラウザと同じで、何もしなければ親矩形に紐づけられたアクションが実行される点(バブリング)も同じにしています。ここをゴリゴリに独自仕様にする理由は一つもないのでGUI作成で一番慣れているWebブラウザの挙動を一部参考にしました。
ポインターキャプチャ
特定の機能を持つ矩形はマウスボタンが押されたときなどにそのポインタをキャプチャします。例えばドラッグで移動可能なスライダーのようなコンポーネントの制御範囲の矩形がこれにあたります。
ポインタをキャプチャするとポインタが矩形/ウィンドウ外にでてもMouseLeaveのイベントが発生せず、引き続き各種ポインタに対するイベントを受け取ることができるようになります。
これをどう実装するかですが、単純にはPointerInputManager
に「現在キャプチャしている矩形の参照ID」を持たせて、各種イベント発生時にキャプチャしている矩形の参照IDがあればヒットテストを行わずにイベントを流す(イベントハンドラを呼ぶ)ことで実装可能です。
ただし今回のアプリケーションではイベントハンドラの引数に&mut PointerInputManager
を渡してしまうとあまりに権限が大きすぎるのでイベントハンドラの返り値のビットフラグに「この要素をキャプチャする」かどうかのフラグをもたせることでPointerInputManager
に処理を閉じ込めています( https://github.com/Pctg-x8/peridot-sprite-atlas-visualizer/blob/ffdd3d3070755c87c54c75b705878408818ffcd4/src/input.rs#L252 )。
こうすると「キャプチャできるのは今処理している矩形のみ」「キャプチャが起こるのは矩形のイベントハンドラを抜けた後」といった制約がつきますが、ポインターキャプチャのユースケースは先にも書いた通りで、大抵はこの制約の中でおさまるため今のところはこれで問題ないと考えています。
クリック判定
GUIにおける「クリック」は「マウスボタンが押されて、一定範囲内で離される」挙動のことをいいます。分解するとマウスボタンの押下~解放までのイベントのライフサイクルは次のようになります。
- マウスボタンが押される
- 押された場所から一定以上の範囲外にポインタが移動した => この時点でドラッグ操作として判定され、クリックイベントは生成されなくなる
- キャプチャしていない要素のとき、押された矩形からポインタが離れた => (移動距離に関係なく)この時点でMouseLeaveイベントを生成し、クリックイベントは生成されなくなる
- マウスボタンが離される => 2.や3.を満たさずにここまで来た場合はクリックイベントを生成する
このため、クリックイベントが起こるのは「マウスボタンを離したとき」となります(他アプリの挙動をよく観察するとほぼすべてこのタイミングのはずです)。
Composition APIをベースとしたViewアーキテクチャ(現状できたもの)
ここまででアーキテクチャの話を書いてなかったので書いておきます。よくあるMVPからちょっとだけ変形したような形をしています。
View
- 純粋にビジュアルのみを制御する
- Composition APIはこのレイヤーのオブジェクトが使う
- 他のViewを参照しない(完全に独立する)
- 自律的に動作しない
- Modelのイベント購読やHitTest Treeのイベントの購読をしない
- ここが一般的なMVPと違う点 Viewがイベントを発してPresenterが受け取る形ではない(Presenterがイベントを
PointerInputManager
経由で直接受け取る) - メソッドを公開し、そのメソッド経由でのみ動作する
Presenter
- 複数のViewをまとめて管理する
- HitTest Treeのイベントハンドラを管理する
- HitTest Treeからのイベントを購読し各種Model/Viewの処理の呼び出しを行う
- Modelのイベント(View Feedback)を購読し各種Viewに更新処理を投げる
- このとき、Modelからのイベントはつぎの2つのどちらかのみ許容される
- Presenter自身の内部状態を変更すること
- Viewを操作すること
- ここからModelを再度操作することはできない
- Modelからのイベントを別のModelに流す必要があるならそれはModel内で完結させるべき
- Modelを別のところでmutable borrowした状態でイベントハンドラを呼ぶ形にしているので、Rustのborrowckがうまくこの制約を作ってくれている
- このとき、Modelからのイベントはつぎの2つのどちらかのみ許容される
Model
- データ層......ではあるが、View(というよりアプリの見た目)に依存したデータ構造で持ってもOK
- よりロジック寄りが必要ならModelの後ろに見えない層を設ければいいのでViewアーキテクチャの視点ではどうでもいい
- 変更をView FeedbackとしてPresenterに伝える
- Viewへの反映に特化したコールバックということで別途名前をつけている
- ただしPresenterを直接持つわけではない(コールバック/Trait Object経由で参照する)
- 詳細な変更点(差分検出など)についてはModelで判定するのではなくPresenter/Viewに判定してもらう
- 最適な更新方法はViewの作りに依存するはず
参照の方向
Rustは基本的には借用で参照を賄うため本来は方向を意識することはあまり必要ないはずですが、残念ながら現実的にはそうはいかず借用で表現できる範囲を超えて参照をもつ必要がでてきます。このとき取れる方法は参照カウンタ(Rc
Arc
)かArenaですが、後者はユースケースが限られるので基本的には参照カウンタを使って管理することになります。
参照カウンタを使うということは循環参照に気をつけなければ簡単にメモリリークしてしまうため「誰がこのオブジェクトを真に保持しているか?」といった参照の方向をしっかり定義する必要があります。
今回は一応以下のような形としてみました。これを書いている現時点ではうまいこといってそうですが、はたして
- Modelが...
- Presenter/Viewを参照する場合: Weak
- ModelのView FeedbackコールバックにPresenter以上を紐づけるとき、Presenter以上のオブジェクトをWeakで持つ形にする
- View Feedbackは現状はただの
Box<dyn FnMut(...)>
であるが、ここのクロージャでPresenter以上のオブジェクトをStrongでキャプチャしないようにする
- Modelを参照する場合: Weak
- でもできればModel同士での参照はやらないようにしたい(下層にもう一個共有のとこを設けて)
- Presenter/Viewを参照する場合: Weak
- Presenterが...
- Modelを参照する場合: Weak
- 基本的にPresenterがModelを持ち続けることはないが、持つ必要が出てきた場合はWeakで持つ(PresenterはModelのオーナーではないので)
- Modelのオーナーは
AppWindowStateModel
とかmain()
とか、そういったほぼアプリのルートのところ
- Viewを参照する場合: Strong
- これはPresenterが配下となるViewを作って管理する層なので当然
- Presenterを参照する場合: 時と場合による
- 正確にはPresenterの親子関係による 親から子ならStrongだし子から親ならWeak
- 兄弟関係の場合はできれば親経由で参照したい(Weakなら直接持ってもまあいいかな......)
- Modelを参照する場合: Weak
- Viewが他View/Presenter/Modelへの参照を持つことは絶対にない
- Viewは独立していなければならないのでComposition API/HitTest Tree以外に依存することはない
- Modelの「データ型」には依存することはあるけど実体への参照を持つことはない
イベントハンドラ(View Feedback)のクロージャの書き方
イベントハンドラをクロージャで書く場合、その外側の変数も参照できる(キャプチャ)ことになります。ただし、Rustはこのキャプチャに対してあまり良い構文を提供しておらず、キャプチャの方法は大まかに次のどちらかになります。
- 先頭に
move
をつける: すべてのキャプチャ対象変数(クロージャ内で参照している変数)がmoveでキャプチャされる(はず) - 先頭に
move
をつけない: すべてのキャプチャ対象変数を原則借用でキャプチャする。内部でmoveしている場合はmoveでキャプチャされる(これもそんな感じだったはず)
内部で参照している変数全てに対しての制御しか提供されていないため、変数ごとに細かなキャプチャはできない他move/借用以外のキャプチャの方法を提示できません(C++はたしかできたような記憶がある)。
ただし、Rustには強力な構文である「ブロック式」があり、これを使うことでC++並の柔軟なキャプチャを記述することが可能です。
init.app_state
.borrow_mut()
.register_atlas_size_view_feedback({
let border_view = Rc::downgrade(&sprite_atlas_border_view);
move |size| {
let Some(border_view) = border_view.upgrade() else {
// parent teardown-ed
return;
};
border_view.set_size(*size);
}
});
register_atlas_size_view_feedback
の引数はクロージャですが、それを生成するのにブロック式を用いてsprite_atlas_border_view
をborder_view
として、Rc::downgrade
を使ってキャプチャしています。
windowsクレートで提供されていないAPIを使う
NuGet経由で追加した拡張パッケージなど、windowsクレートに定義が存在しないものがあります。
もちろんNuGetのネイティブ向けパッケージには大抵の場合DLLが入っているのでインクルードファイルを見ながら自前でFFIを組んで使うこともできるとは思いますが、もしNuGetパッケージにwinmdファイルが同梱されているのであればwindows-bindgen
を使用してwindowsクレートと同様の定義を生成することが可能です。
たとえば、今回のアプリケーションではWin2Dを追加ライブラリとして使っており、これはwinmdファイルを含んでいます。そのため、例えば以下のようにすることで<PROJECT_ROOT>/src/extra_bindings.rs
に定義が自動生成されたファイルが生成されます。
let out_path = project_root.join("src/extra_bindings.rs");
let microsoft_graphics_canvas_winmd_path = project_root
.join(".nuget/Microsoft.Graphics.Win2D.1.3.2/lib/uap10.0/Microsoft.Graphics.Canvas.winmd");
windows_bindgen::bindgen([
"--out",
out_path.to_str().unwrap(),
"--reference",
"windows,skip-root,Windows.Graphics.Effects.IGraphicsEffect",
"--reference",
"windows,skip-root,Windows.Graphics.Effects.IGraphicsEffectSource",
"--reference",
"windows,skip-root,Windows.UI.Color",
"--in",
"default",
"--in",
microsoft_graphics_canvas_winmd_path.to_str().unwrap(),
"--filter",
"Microsoft.Graphics.Canvas.Effects.GaussianBlurEffect",
"--filter",
"Microsoft.Graphics.Canvas.Effects.ColorSourceEffect",
"--filter",
"Microsoft.Graphics.Canvas.Effects.CompositeEffect",
"--filter",
"Microsoft.Graphics.Canvas.Effects.TintEffect",
"--filter",
"Microsoft.Graphics.Canvas.CanvasComposite",
]);
windows-bindgen
の各種引数に関してはドキュメントを見つけられていないのでコードから読み取ったものになりますが、おおむね次のようになっています。
-
--out <FILE>
: 出力ファイルパスの指定 -
--reference <crate_name>,<type>,<fqdn>
: 外部クレートにあるwindows-bindgen
で生成した定義への参照- crate_nameがwindowsの場合のみtypeをskip-rootにでき、その場合は先頭の
Windows.
がモジュールパスに現れなくなる- 例:
windows,skip-root,Windows.UI.Color
=>windows::UI::Color
- 例:
- crate_nameがwindowsの場合のみtypeをskip-rootにでき、その場合は先頭の
-
--in <FILE|default>
: 入力ファイルパスの指定- defaultにするとどこかにある(?)デフォルトのwinmdファイルが入力されたものとみなすらしい
-
--filter <fqdn>
: 実際に出力する定義- 依存している型も自動的に出力されたりはしないので、不足分があれば都度追加する
また、これはwindows-bindgenのバグなのかはわかりませんがなぜかICanvasImage::GetBounds
の定義が二重に出力されるのを確認しており、そのままでは使えないのですごい雑なパッチを当てています。
// ICanvasImage::GetBoundsの定義が何故か二重に出るのでパッチ当てる
let generated = std::fs::read_to_string(&out_path).unwrap();
let generated = generated.replace(
r#"GetBounds: 0,
GetBounds: 0,"#,
"GetBounds: 0,",
);
let generated = generated.replace(
r#"GetBounds: usize,
GetBounds: usize,"#,
"GetBounds: usize,",
);
std::fs::write(&out_path, &generated).unwrap();
DPI Aware対応
近年のディスプレイは20インチ足らずのようなサイズに4K(横3840px)かそれ以上のピクセル数が詰め込まれており、高精度化しています。昔は1インチあたり96px(か72px)の前提で作っておけばよかったのですが、最近はこういった事情により1インチあたり168px[1]といったように現実のサイズに対応するピクセル数が増えています。こういった96DPI以外の環境にも対応して正しい見た目を維持するための諸々の対応が済んでいる状態をDPI Awareであると呼びます。
DTMを長く嗜んでいる人はおそらく経験があるんじゃないかと思いますが、DAWアプリケーション自身がDPI AwareであるにもかかわらずプラグインがDPI Awareでなく、文字がすごく細かいものがあったりします。
自身のアプリケーションでもこういったユーザーに対してフレンドリーではない状態であるのは望ましくないので対応が必要になります。
自身のexeがDPI Awareであることを明示する
Windowsは互換性に重きをおいているためか、何もしない場合はすべてのexeファイルがDPI Awareではない状態になっています。この場合、96DPI時代と見た目のサイズを合わせるためにWindows Shellのコンポジタが勝手にウィンドウの表示内容を拡大縮小します。この結果として古いWindowsアプリでは表示内容がボケたりするわけですが、現代的なアプリケーションではこの挙動は許されません。そこで、Windowsに対してDPI Awareであることを明示する必要があります。これには2つの方法があります。
-
SetProcessDpiAwareness
を使う- ただしリンク先に書いてあるとおり、特に理由がなければマニフェストファイルで宣言するのが良い
- Application Manifestで宣言する
今回のアプリケーションでは後者のApplication Manifestで宣言しています。
Application Manifestを作ったら次はそれをリソースとしてexeファイルに埋め込む必要があります[2]。リソースはまずリソーススクリプトを書き、それをコンパイルしたのちリンカにわたすことで埋め込むことができます。今回は単純にマニフェストだけのリソースなので次のように単純なスクリプトになります。
#pragma code_page(65001)
#include <windows.h>
1 RT_MANIFEST "./peridot-sprite-atlas-visualizer.exe.manifest"
リソーススクリプトができたら次はそれをコンパイルします。今回のアプリケーションではコンパイルもビルドスクリプト内でやっています。
リソーススクリプトのコンパイラはWindows10 SDK内にあるため、レジストリからWindows10 SDKのパスを引っ張ってきて呼び出します。
std::process::Command::new(win10_sdk_bin_folder.join("rc.exe"))
.arg("/I")
.arg(win10_sdk_include_folder.join("um"))
.arg("/I")
.arg(win10_sdk_include_folder.join("shared"))
.args(["/r", "/fo"])
.arg(out_dir.join("exe.res"))
.arg(project_root.join("exe.rc"))
.stdout(std::process::Stdio::null())
.spawn()
.unwrap()
.wait()
.unwrap();
umとsharedへインクルードパスを通すのは必須です(windows.hをincludeしているためです)。
リソーススクリプトができたらそれをリンクするようにcargoに指示します。
リンク名を指定する際に:+verbatim
を付与することで指定したファイル名をそのままリンカにわたすことができるようです(つけない場合はターゲットプラットフォームごとに適当な拡張子が補われたりします https://github.com/rust-lang/rust/issues/81488 参照)。
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rustc-link-lib=dylib:+verbatim=exe.res");
これでDPI Awareであることは明示できました。
座標系変換
生のピクセル数とDIP(Device-independent Pixels、DPIに依存しない座標の単位)の変換は頻出なので関数化しておくとよいです。基本的には「目的のピクセル数=今のディスプレイのDPIを96DPIにしたときのDIP値」として比率の計算をするだけです。
// pixels : x = dpi : 96
// pixels * 96 = dpi * x
// pixels * 96 / dpi = x
pub const fn pixels_to_dip(pixels: u32, dpi: f32) -> f32 {
pixels as f32 * 96.0 / dpi
}
// pixels_to_dipの逆
pub const fn dip_to_pixels(dip: f32, dpi: f32) -> f32 {
dip * dpi / 96.0
}
newtypeでピクセル数とDIP数を型レベルで区別する値を作ってもよいですが、単純にAPIへ渡すときの変換がものすごい面倒なのでそこは好みで良いと思います。今回のアプリケーションのコードでは雑に引数名で明示しておくくらいにしています。
Composition APIの座標系
Composition APIはピクセル座標系です。つまりDIP値をそのまま渡しても小さくなってしまうので都度変換を噛ませる必要があります。
この座標変換は単純に掛け算のみでできるため「ルートVisualにスケールかければ全体をDIP値指定に持ち上げられるのでは?」と思うかもしれませんが、DIP指定とピクセル指定が混ざったときに詰むのであまり横着はしないほうが良いと思います。それでなくても、グローバルにスケールがかかるのでRelativeSizeAdjustment
の挙動が怪しくなります(拡大する方向の場合はぴったりサイズに拡縮で指定してもはみ出たことがあります)。コード量は増えますが、大した計算負荷ではないので都度指定するのが安定だと思います。