backdrop-filter: blur() の高速化

背景(実測で特定)

要素ごとの描画時間を出したところ、単独で突出した要素があった:

[PERF][EL] element draw took 179 ticks. is_img=false, text_len=0, w=924, h=44
[PERF][EL] element draw took 205 ticks. is_img=false, text_len=0, w=924, h=44
[PERF][EL] element draw took 478 ticks. is_img=false, text_len=0, w=924, h=44
[PERF][EL] element draw took  98 ticks. is_img=false, text_len=0, w=924, h=529

924×44(4 万ピクセル)が 924×529(49 万ピクセル)より数倍重い。 面積比例ではない処理が入っている。

正体は backdrop-filter(グラスモーフィズム)。 sugi-lab.net のナビゲーションバーが使っている。

これが paint の未特定分(約 53%)の正体。

現状の計算量

blur_screen_rect は素朴な 2 次元ボックスブラー:

for y in 0..h {
  for x in 0..w {
    for dy in -r..=r {
      for dx in -r..=r { /* 範囲内なら加算 */ }
    }
  }
}

O(W×H×(2r+1)²)。r は 1〜8 にクランプされるので最大 17×17 = 289。 924×44 なら 1170 万回の加算。毎フレーム。

最適化: 累積和(prefix sum)による厳密な等価変換

ボックスブラーは各ピクセルについて クリップされた窓の総和 ÷ 窓内の画素数(整数除算・切り捨て)である。

窓は矩形なので、x 方向の範囲は y に依らず、y 方向の範囲は x に依らない。 したがって画素数は n = nx(x) × ny(y) と分解できる。

2 次元の総和も

S(x,y) = Σ_dy Σ_dx src = Σ_dy H(x, y+dy)

と分解できる(H は行方向の窓和)。 総和のまま持ち回れば除算は最後の 1 回だけになり、 現行実装とビット単位で同一の出力が得られる。

さらに H は行ごとの累積和から O(1) で引けるので、 全体は O(W×H)(半径に依存しない)。

r=8 のとき 1 ピクセルあたり 289 サンプル → 定数回。

不変条件

モジュール

web_engine/blur_box.rs(純粋・no_std・グローバル状態なし)

/// 窓の範囲(両端含む)を矩形内へクリップして返す。
pub fn window_bounds(i: usize, r: usize, n: usize) -> (usize, usize);

/// パック済み RGB(上位 8bit は無視)に対するボックスブラー。
/// 出力は素朴な 2 次元実装とビット単位で一致する(B-1)。
pub fn box_blur_rgb(src: &[u32], w: usize, h: usize, radius: usize) -> alloc::vec::Vec<u32>;

実装後の実測

                        ナビバー要素      paint 全体
修正前                  179〜478 tick     243〜305 tick
修正後                   18〜 24 tick     122〜151 tick

make test 765 件パス(test_blur_box 21 件を新規追加。 うち 6 件は素朴実装との総当たり突き合わせ)。

計測の帳尻が合った

paint=122  bgimg=31  grad=53  fill=19  pixbg=17  text=1
→ 合計 121

未特定分が 53% → ほぼ 0% になった。 paint の 53% を占めていた「どのプリミティブにも計上されない時間」は、 すべて backdrop-filter のブラーだった。

次に重い要素

[PERF][EL] element draw took 98〜134 ticks. w=924, h=529

ヒーロー領域(背景画像+グラデーション)。 これは面積相応(49 万ピクセル)なので、 ブラーのような異常な定数倍ではない。