Rust/wgpu製3Dビューアにeguiを統合した。Intel HD 530での性能差とGUI刷新の実装・検証記録

Rust/wgpuで作っている住宅向け3Dビューアに、2026年10月24〜25日にGUIを後付けしました。既存のwgpu Rendererには触れず、egui(egui-wgpu/egui-winit)を「3D描画の後にもう1パス足す」形で統合しています。この記事は、その作業記録から既存のRust/wgpuアプリにGUIを後付けするときの設計判断・性能への影響・検証方法を取り出したものです。

先に結論です。

  • 既存Rendererを改造せず、既存のwgpuのDevice/Queueを利用し、3D描画後にeguiの描画パスを追加する構成でGUIを載せられた。Intel HD 530・1920×1080の測定では、GUIなしに対する新GUI(パネル開)のフレーム時間p95の増加は、3視点で+0.39〜+0.63 ms。
  • 3D操作とGUI操作の競合は、座標のしきい値ではなく「押下時に所有者を決め、離すまで保持する」規則で分離した。合成入力による入力試験は25項目すべてPASS。
  • デザインはAIデザインツール(Claude Design)が作ったHTMLプロトタイプを仕様の正本にした。ただし最初の実装は「旧開発用GUIと変わらない」見た目で、標準ウィジェットをやめて自前描画に置き換える段階が必要だった。
  • シーン(視点+閲覧状態)は住宅データ(HouseDoc)と別ファイルに保存し、HouseDocのschemaは変えなかった。
  • 一方で、実Windowsの実マウス・実IME・実モニタ倍率の確認は自動テストでは代替できない。測定は3反復・単一環境で、製品全体が60fpsを達成した、といった主張はしない。

対象はStage 0〜3(commit 50d2dbe〜19a1d74)です。それ以降のStage(画像出力など)は範囲外です。なお、Renderer側の設計(編集用と出力用の分離)は以前の記事(住宅CADの3D表示をBlender Cyclesに分離した理由)に書いたので、ここでは繰り返しません。

目次

旧GUIで不足していた閲覧機能と刷新の目的

もともとのViewerは、画面左にGDIで描いた開発用パネルを置いていました。プラン切替、壁編集、Undo/Redo、取込モデル一覧がボタンとして縦に並ぶだけの構成です。

旧GUIと新GUIの比較
図1 左:旧GUI(P03を表示)。右:新GUI(P02を表示)。住宅プランが異なる点に注意。旧GUI画像は、画面下部のファイルパス表示を含む部分を切り落としている。

旧GUIで不足していた閲覧機能は次のとおりです(画面と一次記録から、設計上の改善対象として整理したもので、利用者からの不満を集めたものではありません)。

  • 部材を探す・隠す・単独で見る、といった「閲覧」の操作が用意されていない。
  • 操作ボタンが全部同じ重さで並び、閲覧用と編集用が区別されない。
  • 視点を保存して戻る手段がない。

新GUIの目的は、これらを「閲覧のための画面」として組み直すことでした。住宅の形状データ(HouseDoc)、Store、描画エンジンは変えない、という制約を最初に置いています。起動時は--new-guiを付けたときだけ新GUIにし、付けなければ従来のGUIがそのまま動くようにしました。

新GUIの設計方針

デザインの正本はHTMLプロトタイプ

画面のデザインは、Claude Designで作成したHTMLプロトタイプ(「模型台+シーン帯」の構成)を採用しました。ここで強調しておきたいのは、このプロトタイプはAIが生成したデザイン案であり、Rust実装そのものではないことです。実装側は、HTMLから次のものを転記して使いました。

  • CSSのデザイントークン(色・寸法・角丸。ライト/ダーク)
  • アイコンのSVGパスデータ(アイコンフォントや画像は使わず、パースしてeguiのPainterで描く)
  • イベントハンドラと初期状態から読み取った操作仕様

UIは「読むだけ」と「命令を返すだけ」

UIコードは、読み取り専用のSnapshotを受け取って描画し、Commandを返すだけにしました。シーンやHouseDoc、GPUの状態をUIが持つことはありません。Commandの適用は、旧GUIが使っていた既存関数(カメラ操作、プラン切替、取込モデルの表示切替など)を呼ぶ薄い境界に集めています。下は考え方を示す簡略化したスケッチで、実コードそのものではありません。

// 簡略化したスケッチ(実コードではない)
struct Snapshot { /* 表示中の住宅、選択、可視性、Undo可否などの読み取り専用コピー */ }
enum Command { FitAll, FocusSelection, SwitchPlan(PlanId), SetImportVisible(ImportId, bool) /* ... */ }

fn view(ui: &mut egui::Ui, snap: &Snapshot) -> Vec<Command> { /* 描くだけ */ }
fn apply(app: &mut App, cmd: Command) { /* 既存の関数を呼ぶ */ }

この分け方にしたのは、後の「保存・復元」や「閲覧フィルタ」を足すときに、GUIの状態と住宅データの責務が混ざらないようにするためです。

eguiを既存wgpuに統合する

依存の解決

Stage 0で最初に確認したのは、eguiの追加で既存のwgpu/winitが二重化しないかでした。egui 0.37.0、egui-wgpu 0.37.0、egui-winit 0.37.0は、既存のwgpu =30.0.1・winit =0.30.13のまま解決され、cargo tree -iで各1バージョンのみを確認しています。既存ロックのバージョンは変わらず、Cargo.lockの差分は追加のみでした。新規crateは34個で、大半がMIT OR Apache-2.0系です(Apache-2.0 OR GPL-2.0-onlyが1件あり、Apache側を選択。cargo-denyは未実施)。

描画は「3Dの後に1パス足す」

統合の中心は、3D描画が終わった後にUI専用のrender passを1つ追加することです。このパスはLoadOp::Loadで3Dの結果の上に描きます。既存のwgpuのDevice/Queueを利用し、3D描画後にUIの描画パスを追加する構成としました。住宅のシェーダとRendererは改造していません。サーフェスのフォーマット(sRGB)はegui-wgpu側が自動で合わせます。

もう1つ入れた工夫は、UIパスを「何か変わったときだけ」実行することです。入力があった、新しいSnapshotが来た、eguiがアニメーションを要求した、のいずれでもなければ、前回のペイントジョブをそのまま再描画します。Stage 1の測定では、約1,300フレーム中UIパスが走ったのは約25回でした。

日本語フォント・IME・DPI

  • フォント:Windowsのシステムフォント(Yu Gothic、なければMeiryo、MS Gothic)を起動時に読み込みます。リポジトリへのコピーはしません。読込は約38 ms(13.7 MB)。UIの全ラベルの字形が存在することをテストで確認しました。
  • IME:winitのIme::Preedit/Ime::Commitを実イベント経路で投入し、変換中「にほん」が入力欄に出て、確定「日本語」で置換されることを確認。入力欄にフォーカスがある間は、R・Bなどのビューアのショートカットを発火させません。
  • DPI:寸法はすべて論理pxで定義し、pixels_per_pointで拡大します。150%・200%は環境変数でモニタ倍率に掛け算して模擬しました。

なお、実際のWindows日本語IMEの候補ウィンドウと、実モニタの倍率変更は、自動テストでは確認できていません(後述の人間確認の対象)。

GUIと3D操作の入力競合

3Dビューアに後付けでGUIを載せると、「パネルの上でドラッグして3D側が回る」「3Dで押したままボタン上で離してクリック扱いになる」といった競合が起きます。画面左端のx座標でパネル領域を判定する方式は、パネルの開閉・倍率・狭幅表示のたびに壊れます。

そこで、座標のしきい値は使わず、次の規則にしました。

  • eguiがすべてのイベントを最初に受け取る。
  • ボタンを押した時点で、所有者(カーソル下のUI部品か、3Dシーンか)を決める。
  • 所有者は、すべてのボタンが離されるまでそのジェスチャ全体を保持する。
  • 入力欄がキーボードを持っている間は、IMEとキーをUIへ回す。

この所有権ロジック(Router)は、単体テスト4件で検証しました。さらに、実アプリのイベント経路(window_event_inner)に合成入力を投入する試験(25項目)を作り、次を確認しています。

  • 3D領域のドラッグで回転する。UI上で押したドラッグは、3D上へ出ても回さない。
  • 3Dで押したドラッグがUIボタン上で離されても、クリックにならず、カメラ操作が継続する。
  • ホイールは、パネル上ではズームせず、3D上でする。
  • 選択カード上のクリックで、3Dの選択が変わらない。
  • 検索欄にフォーカス中はR/Bが発火せず、ステージをクリックするとフォーカスが外れてRが効く。
  • 100%・150%・200%(模擬)で同じ分離が成立する。

結果は25 PASS/0 FAILです(Stage 1)。ただし、これはOS入力ではなくアプリ内に投入した合成イベントです。実マウスのホイールの感触は、Stage 1・2・高忠実度再現のすべての段階で「持ち越しの人間確認」として残しました。

HTMLプロトタイプと実装画面の比較

Stage 2までのGUIは、見た目の面で失敗していました。eguiの標準ボタンやチェックボックスで構成した結果、一次記録の言葉では「旧開発用GUIと変わらない」状態でした。そこで、標準ウィジェットをやめ、HTMLのCSSトークンとSVGアイコンを使った自前描画の部品に置き換える高忠実度再現の段階(commit 10a4766)を設けました。

Stage 2、高忠実度再現後、Claude DesignのHTMLの比較
図2 左:Stage 2(a1ab313)。中:高忠実度再現後。右:Claude DesignのHTML(AI生成のデザイン案)。3D表示の見た目(光・影・庭の外構)がHTMLと異なるのは、描画エンジン側は変えていないため。

比較は、HTMLを実ブラウザ(Chrome headless)で操作して撮影した画像と、Rust側の同じ状態を、11状態×2解像度(1920×1080/1366×768)で並べて行いました。下は、部材を選択した状態の比較です。

HTMLとRust新GUIの選択状態の比較
図3 左:Claude DesignのHTML。右:Rust新GUI。注視中のカメラが異なるため、3Dの構図は一致していない。比べているのはパネル・インスペクタ・ツールバーの構成。

比較で意識した点は2つです。

  • 忠実度の測り方を決める:差分を一覧にして、再現すべきもの(A)と、Rust側に機能がないため再現できないもの(B:影・室内/階別モードなど)、情報がないもの、後回しにするものに分類しました。再現できない機能を、動くデモのように見せないことも決めています。
  • 確認できていないことを明記する:次の段階(スタート画面・操作遷移、commit 63215d0)では、ユーザー承認のうえでHTMLの実ブラウザ操作を必須条件から外しました。そのため、操作仕様はHTMLソース(初期状態、イベントハンドラ、CSS)から確認したもので、「HTMLの実操作と一致した」とは報告していません。比較画像も、過去のHTML実画像と今回のRust画像の並置と、HTMLソースの配置図とRust画像の並置を、区別して記録しました。

この段階で追加した操作は、拡大/縮小、「これだけ表示」の解除、F/Z/H/Iキー、ダブルクリック注視です。応答配置は、1180px以下でキー表記を省略、980px以下で小型インスペクタ、760px以下でパネルを重ねて下部シートにする、というHTMLの段階を再現しました。

低スペックGPUでの性能測定

GUI追加が描画性能に与える影響は、Stageごとに測りました。測定条件が異なるので、段階をまたいだ数値は直接比較できません。表は条件ごとに分けています。共通条件は、Intel HD Graphics 530、D3D12、vsync off、FQ-B+cull構成(描画側の設定)、各run前後にリモートデスクトップがActiveであることを確認、です。

Stage 0/1:GUIなし・旧GUI・新GUIの比較(1920×1080)

3反復×5構成×3視点=45 runがすべて有効でした。フレーム時間p95(ms)です。

視点A GUIなしB 旧GUIC 新・パネル閉D 新・パネル開E 新・毎フレームUI(上限)
E1 静止(外観)8.388.698.879.019.08
I1 静止(室内)7.277.327.207.668.01
E1 周回9.109.459.249.699.78
表1 Stage 1のフレーム時間p95(ms)。
  • 新GUI(パネル開、D)−GUIなし(A)=+0.63/+0.39/+0.59 ms(E1静止/I1静止/E1周回)。
  • 新GUI(D)−旧GUI(B)=+0.32/+0.35/+0.24 ms。毎フレームUIを走らせる上限(E)でも、旧GUIとの差は最大+0.69 ms。
  • UIパス1回のCPU時間はp50 0.45 ms。UI由来の50 ms超の停止は、新GUIの全15 runで0件。
  • Private Bytes最大は、旧GUIの866〜870 MBに対して新GUIは882〜886 MB(+約16 MB)。GUIなしの604 MBとの差は、既存の外部取込用テクスチャ予約によるもので、eguiによるものではない(旧GUIでも同じ)。
  • 起動から初回フレームまでは、旧GUIの約1.73秒に対して新GUIは約1.78〜1.95秒。egui初期化に約0.4秒(主にシェーダのコンパイル)。

当時は「旧GUI比で概ね+1.0 ms以内」を目標にしていて、この結果は目標内でした。ただし、GPUタイムスタンプは3Dパスのみを測るので、UIパスのGPU時間は単独では測れていません(フレーム間隔の差に含まれます)。変更前のEXEの測定は、設定の違う古いビルドで比較に使えなかったため除外し、旧GUIの回帰は後述のバイト一致で担保しました。

Stage 2:部材一覧とインスペクタを足した後(1920×1080)

Stage 1の保存EXEと、Stage 2の最終EXEを交互に実行し、対応のそろった組だけで比べました。

条件反復Stage 1 p95 msStage 2 p95 ms差 ms
E1 静止(最終EXE)18.3958.870+0.475
E1 静止・選択中(最終EXE)28.4108.889+0.479
表2 Stage 2の最終EXE。約0.5 msのp95増加はこの条件での値で、反復は1〜2組と少ない。

最終EXEのp50差は、通常+0.121 ms、選択中+0.324 ms。Private Bytesの差は約+0.2〜+1.8 MBでした。Stage 2では、DXF境界補正前の候補EXEで周回・室内も測っています(周回+0.045 ms、室内+0.109 ms)が、最終EXEですべての視点を再測定したわけではありません。また、無効にしたrunの1件に54.003 msのフレームがあり、原因は特定できていません(0件に書き換えていません)。

高忠実度再現後:Stage 2との比較(1920×1080)

条件反復Stage 2 p95 ms今回 p95 ms差 ms
E1 静止38.7329.295+0.56
E1 静止(再測定)48.8159.357+0.54
I1 室内 静止37.7447.816+0.07
E1 周回39.34310.425+1.08
E1 周回(再測定)49.8279.715−0.11
表3 高忠実度再現(10a4766)の測定。

周回は+1.08 msと−0.11 msで符号が割れ、測定ばらつき(約±1 ms)の範囲と判断しました。静止状態の+0.5 ms程度(約6%)は2回の測定で一貫していました。この段階のUIパスp50は0.6 msから1.0〜2.0 msに増えています(行とアイコンの描画が増えたため)。メモリは+13〜18 MBで、追加したフォントの読込分です。

スタート画面と、シーン保存(1200×700)

以降の2つの段階は、解像度を1200×700に変えて、直前のcommitから別ビルドした基準EXEと比べました。1920×1080の表とは絶対値を比べられません。

段階条件基準 p95 ms今回 p95 ms差 ms
スタート画面(63215d0)E1 静止7.6497.657+0.008
同上E1 周回7.9698.119+0.150
Stage 3 シーン保存(8434212)E1 静止5.8656.161+0.30
同上E1 周回6.3747.117+0.74(+11.7%)
表4 各12 run有効、無効0。Stage 3の周回の増加の原因は未特定。

Stage 3の周回で+11.7%という、このシリーズで最大の増加が出ました。反復は3回・単一環境で、50 ms超のフレームは基準と今回で同数(周回で1件ずつ)です。原因の切り分けは未実施なので、「シーン帯のせい」と断定はしていません。この増加の原因は未特定であり、GUI統合のみの影響と断定できません。

性能の読み方

  • これらは静止状態や自動周回が中心で、実マウス操作中の体感は測っていません。
  • Intel HD 530・リモートデスクトップ接続中の1環境だけの結果です。他のGPUは未測定です。
  • 製品全体として60fpsを達成した、とはいえません。フレーム時間p95は、本記事の条件で6〜10 ms台でした。

シーン管理・保存・復元

Stage 3では、視点と閲覧状態を「シーン」として保存できるようにしました(画面下部中央の帯、サムネイル、Alt+1〜9で復元)。

シーン帯の画面
図4 視点を3つ保存した状態(P02)。下部中央がシーン帯、右下はシーン追加のトースト通知。

責務の分離:HouseDocには書かない

シーンは住宅形状データ(HouseDoc)とは別のJSONファイルに保存します。HouseDocのschema、Store、描画は変えていません。ファイルには、format="building-viewer-scenes"、version=1、subjectsのFNV-1aチェックサム、住宅ごとのsubjectを持たせました。subjectのキーはplan:P02、house-file:<ファイル名>、import:<ファイル名>などで、住宅ごとにシーンを分離します。

個別部材の識別子は、保存できるものだけ保存する

セッション内のPartIDは保存しません。保存できる安定した参照があるのは、登録プラン(--plan)だけで、宣言要素IDとスロットからなるPieceKeyを使います。復元時は現在の台帳と照合し、一致しない参照は適用せず、件数を通知します。

一方、--house-fileだけで開いた住宅(台帳なし)、検証用住宅、外部のGLB/glTF/DXFには安定した参照がないため、個別の「非表示」「これだけ表示」は保存しません。保存時に件数を警告し、保存ファイルのunsaved_part_stateに件数を記録します。「保存できたように見せない」ことを優先しました。

保存の安全性

  • 同じフォルダの一時ファイルに書く → sync → 読み戻して検証 → rename。失敗時は既存ファイルに触れない。
  • HouseDocの元ファイル、外部モデルのパス、.glb/.gltf/.dxfなどの拡張子は、保存先として拒否。
  • 読み込み時は、format・version・checksum・名前/ID/カメラ値の範囲・件数/サイズ上限を検証し、1つでも不正ならファイル全体を拒否。

Undo/Redoは2系統に分ける

HouseDocの履歴とシーン操作の履歴は別です。Ctrl+Zは「直近の変更がシーン操作ならシーン、そうでなければHouseDoc、どちらもなければシーン」という1つの規則で対象を決め、キー・タイトルバーのボタン・活性状態をすべて同じ関数で判定します。カメラ移動や表示切替は履歴に入れません。

自動テストと人間確認

自動テストの結果

段階テスト結果
Stage 0/1(50d2dbe)入力分離(実アプリ・合成入力)/Router・フォント・IME単体25 PASS/6 PASS
Stage 2(a1ab313)新GUI単体+既存回帰/実アプリ機能試験194 PASS/46 PASS
高忠実度再現(10a4766)単体/操作対応/入力分離/Stage 2機能194/117/25/46 PASS
スタート画面(63215d0)単体・回帰/操作対応/スタート・全10サンプル等199/117/100 PASS
Stage 3(8434212)scene_book単体/実アプリ・シーン操作/GLB不変9/40/4 PASS
Stage 3修正(94760c0)lib単体/修正検証/シーン操作568件中全PASS/43/40 PASS
Stage 3修正(19a1d74)lib単体/ダイアログ(実Windowsダイアログ含む)569/21 PASS
表5 各段階の記録上のテスト結果。失敗は0(初回の失敗は修正後に再実行)。段階をまたいだ件数の合算はしていない。

回帰の保護として、旧GUI(--gui)の画面を、変更前の実行ファイルと変更後でバイト単位で比較しました(Stage 1・2・高忠実度再現・Stage 3)。同じ条件の画面BMPが一致しています。ただし、GPUを使う重量級テストの全件実行は、メモリへの配慮から毎回は行っていません。

人間が確認した範囲と、見つかった問題

自動テストは、アプリ内に投入した合成イベントで動いています。そのため、次の項目は人間の確認として残しました。

  • 実Windows日本語IMEの変換(候補ウィンドウ)
  • 実モニタの150%/200%倍率(リモートデスクトップの接続変更を含む)
  • 実マウスの感触、特にホイール入力の分離
  • Windowsのファイルダイアログ、デザインの最終的な見た目

Stage 1の人間確認(3Dカメラ操作、日本語IME、ファイルダイアログ、ライト/ダーク、ドラッグ入力分離)は受入済みとして記録されています。Stage 3では、この人間確認で次の不具合が見つかり、修正しました。

発見した問題原因修正
タイトルバーのUndo/Redoが押せない編集は旧GUIの担当という前提で、ボタンが常に無効に固定されていた。履歴状態がSnapshotになかった(キーのCtrl+Z/Yだけ別経路で動いた)履歴状態をSnapshotに追加。判定規則を1か所にまとめ、キー・ボタン・活性表示を統一(94760c0)
再起動するとシーンが復元されない保存ファイル自体は正しかった。「名前を付けて保存」で選んだ場所をどこにも記憶せず、--planで起動する登録プランには自動読込先がなかった。修正前のEXEで、再起動後にシーン0件になることを再現保存先を住宅キーごとに記憶し、次回起動時に自動で読む(同上)
「シーンファイルを開く…」を押しても何も起きない未保存のシーンがあると、ダイアログを開かず6秒で消えるトーストを出して終わる仕様だった。シーンを追加した直後は必ず未保存になる「保存して開く/破棄して開く/キャンセル」の確認を追加。保存に失敗したら開かず、現在のシーンを失わない(19a1d74)
表6 Stage 3の修正記録に残っている不具合と修正。

3つ目は、再現手順を記録したことで原因(ダイアログが開かないのではなく、確認なしに早期returnしていた)が特定できた例です。「ボタンを押しても反応しない」という報告だけでは、ダイアログの所有ウィンドウの問題と取り違えるところでした。

今回の実装で分かったこと

  • 既存Rendererを触らずに済ませるには、UIを独立した描画パスにする。既存のDevice/Queueを利用し、UIは3Dの後の独立パスにしたことで、旧GUIの画面はバイト一致を保てた。
  • 入力の分離は所有権で決める。座標しきい値だと、倍率・パネル開閉・狭幅のたびに壊れる。
  • 「動いている」と「デザインに近い」は別。Stage 2は機能的には動いていたが、見た目は開発用GUIのままだった。デザイン正本からトークンとアイコンを転記する段階が必要だった。
  • 保存できないものは保存したと見せない。安定した識別子がない状態は件数を警告し、ファイルにも記録する。
  • 測定は条件ごとに表を分ける。解像度・基準EXE・反復数が違う数値を1本の線につなげない。

未解決事項と今後の課題

  • 実マウスのホイール、実IMEの候補ウィンドウ、実モニタの倍率変更は、自動検証では確認できていない。
  • Stage 3の周回で+11.7%(p95)の増加が出た。シーン帯が原因かは未切り分け。
  • UIパスのGPU時間は単独で測れていない。
  • HTMLプロトタイプの実ブラウザ操作との一致は、スタート画面以降の段階で確認していない(HTMLソースから仕様を確認)。
  • 室内/階別モード、表示スタイル(影・白模型)、シーンの画像出力は、Stage 3の時点では無効表示。個別部材の安定参照は、登録プラン以外にない。
  • サムネイルにDXFの重ね表示が含まれない。サムネイル生成は1フレーム分(17〜42 ms)ブロックする。
  • Stage 3の時点では、未保存シーンのままウィンドウを閉じても警告されなかった(後続のbb00a8cで終了確認を追加)。

今回の測定は、3反復・Intel HD 530・リモートデスクトップ接続中の1環境で、製品の動作を保証するものではありません。他のGPU、実マウス操作中の挙動、長時間使用時の挙動は未測定です。