ginokent Blog
RSS EN

ペイントツールのレンダリング最適化で 3 つのアプローチを試した話

自作のグラフィックライブラリを使ってペイントツールを作っている。 2000 〜 8000 ストロークあたりで FPS がめちゃくちゃ落ちる問題にぶつかったので、 3 つのアプローチを試した記録を残しておく。

前提

  • バックエンドは wgpu
  • 毎フレーム全プリミティブの頂点バッファを再構築 (build_batches()) する設計
  • 8000 ストロークで FPS ≈ 1 まで落ちる

ボトルネックは明らかに build_batches() で、毎フレーム数千ストローク分の頂点を再計算・再コピー・再アップロードしているため、ストローク数に比例してフレームタイム (=1フレームを描くのにかかった時間) が増加する。

アプローチ 1: GPU Transform + 頂点キャッシュ + GPU バッチキャッシュ

3 段階の最適化を計画した。

  1. GPU Transform 移行: transform 計算を CPU から vertex shader に移す。CPU 側の apply_transform_to_vertices() を廃止し、dynamic uniform buffer で transform を GPU に渡す
  2. 頂点キャッシュ: ストロークごとに push_polyline() の結果をキャッシュし、再計算をスキップ
  3. GPU バッチキャッシュ: 確定ストロークのバッチを GPU バッファごとフレーム間で再利用し、memcpy + GPU upload を丸ごとスキップ

何が問題だったか

頂点キャッシュのキーに Rc のポインタアドレスを使ったところ 2 つの問題が発生した。

  • アロケータのメモリ再利用: 解放済みアドレスに別のデータが入り、キャッシュが古いデータを返す (表示がチラつく)
  • Rc::make_mut の in-place 変更: データが変わってもアドレスは同じなのでキャッシュが無効化されない (移動が反映されない)

後から考えれば当たり前の話だが、ポインタアドレスはキャッシュキーとして使えなかった。
フィンガープリント方式 (先頭・中央・末尾の 3 点サンプリング) で応急処置したが、どう考えてもヒューリスティックなのでやめた。
最終的に DrawPrimitivecache_key: Option<u64> を追加し、データのプロデューサーが明示的にキャッシュ可能性を宣言する方式にした。


GPU バッチキャッシュも問題が多かった。

  • メニュー UI と確定ストロークが同一バッチに混在して、メニューが消える
  • バッチ爆発で GPU メモリ枯渇 → クラッシュ
  • ペン先座標のズレ

build_batches() 内部でバッチの確定/動的分離をやろうとすると OpacityGroup との相互作用やインデックス整合性の管理で組み合わせが爆発する。
結果的に GPU バッチキャッシュは丸ごとリバートした。

アプローチ 2: DrawList の structural hash でスキップ

build_batches() の内部を変更せず、render() レベルで「DrawList が前フレームと同一ならば build_batches() + Phase 1 を丸ごとスキップ」する方式を考えた。


が、完全に無意味だった。


理由は単純で、自分が作っている UI は即時型 UI で完全静止時 (何も操作していない状態) はそもそも render() が呼ばれない設計だった。
FPS が問題になるのは常に操作中 (描画中、移動中、選択アニメーション中) であり、DrawList は毎フレーム変化する。structural hash は常にミスする。


最適化対象の前提条件を確認せずに設計を進めてしまったのが原因。アホすぎる。

アプローチ 3: 頂点 Vec 再利用 + ラスタライズキャッシュ (最終決定)

「96MB の memcpy がボトルネック」という推測で設計を進めていたが、ここで初めてちゃんと計測した。

処理所要時間割合
vcache_ext (2000 個の小さな extend_from_slice)7.4ms45%
GPU upload (Phase 1)4.6ms28%
Phase 21.6ms10%

短期: 確定ストロークの頂点 Vec 再利用

バッチ単位で確定ストロークの頂点 Vec をフレーム間で保持する。

  1. std::mem::takeVec ごと move (zero-copy)
  2. 動的コンテンツ (カーソル、選択 UI 等) を末尾に追記
  3. GPU upload
  4. truncate で動的部分を切り落として返却

キーはバッチ位置ではなく cache_key 列のハッシュであるため、バッチ構造が変わっても (例: 移動でストロークが別バッチに移動) スパイクなく再利用できる。


なお Vec::append は内部で memcpy を行うので zero-copy ではない。std::mem::takeVec ごと move する必要がある。


スクロールの選択&移動操作は、各頂点を CPU でオフセットする方式から PushTransform(translate(dx, dy)) で GPU transform に変更した。

長期: ラスタライズキャッシュ

確定ストロークをレイヤーテクスチャに焼き込み、描画時はテクスチャ 1 枚の表示のみにする。描画コストが O(1) になり、ストローク数に依存しなくなる。

  • 編集時のみベクターデータとして扱う (ラスタライズキャッシュ + オンデマンドベクター編集)
  • Undo は部分テクスチャバックアップ方式 (ストロークの bounding rect 領域のみバックアップ・復元)
  • GPU メモリ管理は初期実装では 1 レイヤー = 1 テクスチャ、将来的にタイリング方式に移行可能

振り返り

  • ポインタアドレスをキャッシュキーにしない。アロケータはアドレスを再利用するし、Rc::make_mut 等で in-place 変更した場合もアドレスは不変。キャッシュの正確性にはプロデューサーが明示的にキーを管理する方式が必要
  • 最適化の前に計測する。推測ベースで設計を進めると、アプローチ 1 のように複雑化してから手戻りになる。N フレーム平均を定期出力する方式なら、毎フレームログ出力による計測汚染を防げる
  • 最適化対象の前提条件を確認する。「静止時にスキップ」は、そもそも静止時に描画が走らないフレームワークでは意味がない。問題が発生する条件を正確に把握してから設計すべき
  • 複雑な内部ロジックに手を入れるより、入出力をキャッシュする方がシンプル。build_batches() の内部を改造するより、その入力 (頂点 Vec) や出力をキャッシュする方が安全で効果的だった
  • 大量ベクターの毎フレーム再送信には限界がある。数千ストローク × 数百頂点 × 60 bytes で数十〜数百 MB/フレームになる。ストローク数にスケールしない描画方式 (ラスタライズキャッシュ) への移行が最終的な解になる
  • Vec::append は zero-copy ではない。Rust の Vec::append(&mut other) は内部で memcpy を行う。true zero-copy には std::mem::takeVec ごと move し、使用後に truncate で返却する