Lulucat

チョークツールのパフォーマンス:フルスクリーンパスから scissor 矩形へ

Gaoge ZhangGaoge Zhang

Lulucat Notes のチョークツールは、手書きが密集した領域で遅くなりました。ボトルネックは 3,571 個の入力サンプルではなく、フレームごとに実行される 70 回のフルスクリーン scratch pass でした。却下した低解像度キャッシュと、ストロークごとの scissor 矩形がその経緯を示します。

255% ズームの iPad 上の Lulucat Notes を切り取った表示。赤と青のチョークで書かれた中国古典の一節と、アプリのツールバーの一部が見える。

最終確認用デバイスビルドの赤と青のチョーク、255% ズームで 155 ストローク。

Lulucat Notes のチョークツールには、特有のパフォーマンス問題がありました。何もない領域への書き込みは滑らかですが、すでにチョークストロークで埋まった領域へ移動すると、ペン先が遅れ始めます。同じ領域で書き続けると、キャンバスのパンも徐々に遅くなりました。

普通の手書き 1 ページだけで問題を引き起こすには十分でした。300% ズーム、ローカル領域に見えているチョークストローク 70 本、合計 3,571 個の入力サンプル点です。何もない領域は滑らかなままで、遅くなるのはストロークが集中している領域だけでした。

修正後は、同じページに 255% ズームで新しい文字を書き続けられます。ペンを下ろしている間もパンしている間も、既存のストロークは完全な鮮明さを保ちます。

255% ズームの iPad 上の Lulucat Notes に、赤と青のチョークの手書きが表示されている。本文には「天行健,君子以自强不息;地势坤,君子以厚德载物」と書かれている — 中国古典の一節である。右上には青い Lulucat マスコットがある。下部のツールバーにはストローク数 155、Save、Clear、255% のズームスライダーが表示されている。

最終確認用デバイスのスクリーンショット、合計 155 ストローク。このズームレベルでは、ペンを下ろしてもパンしても、既存ストロークの鮮明さが一時的に変わることはありません。

チョークに scratch テクスチャが必要な理由

普通のペンなら、円形のスタンプを 1 つずつ source-over blending でインクテクスチャに直接合成できます。チョークには grain-gating のレイヤーがあります。レンダラーはまずストローク全体の body coverage と depth を蓄積し、次に固定 grain テクスチャを使ってチョークの粉が付く位置を決め、最後にその結果を既存のインクへ合成します。

この scratch テクスチャは、1 本のチョークストロークを分離します。分離が重要なのは、同じストローク内のスタンプが大きく重なるためです。各スタンプを個別に grain gate すると、ストロークの中心線に色が繰り返し蓄積し、チョークの細孔がサンプリング密度に合わせて移動してしまいます。

高いズームレベルでは、Lulucat Notes は現在の viewport に見えているベクターストロークを再描画します。旧実装は、見えているチョークストロークごとに次の手順を実行していました。

  1. main render encoder を終了する。
  2. scratch テクスチャをクリアする。
  3. この 1 本のチョークストロークを scratch に描画する。
  4. main render encoder を再び開く。
  5. フルスクリーンの三角形で scratch を drawable に合成し戻す。

1 本のストロークの意味は正しかったものの、作業範囲が大きすぎました。iPad の drawable は 2732×2048、約 5.6 百万ピクセルです。チョークストローク 1 本ごとに scratch pass 1 回とフルスクリーン composite 1 回が発生します。チョークストローク 70 本では、約 141 個の render encoder と 70 回のフルスクリーン composite になります。

見えているチョークストローク数を 、drawable のピクセル数を とします。ピクセルカバレッジに応じて増える処理だけを考えると、旧実装はおよそ

でした。各チョークストロークには固定の render-pass overhead もあるため、そのコストも に対して線形に増えます。3,571 個の入力点が加えるのは二次的なコストにすぎません。ローカルなストローク数とともに増えていたのは、ストロークごとに発生するフルスクリーンの作業範囲でした。

測定には、iPadOS 18.6.2 を実行する 12.9 インチ iPad Pro(第 5 世代、M1)を使いました。この iPad 固有の同じ Debug デバイスビルドで command-buffer timestamps を使い、同じ viewport の変更前後の GPU timestamps を比較しました。以下の範囲は複数フレームのログに見られる典型的な揺れであり、配布版のフレームレートを約束するものではありません。見えているチョークストロークが 70 本の場合、1 フレームに必要な GPU 時間は通常 52–60 ms でした。およそ 120 本のストロークがある領域では、GPU 時間は 77–80 ms に上がりました。

scratch と composite のフルスクリーン矩形の面積から見積もると、フレームごとの理論上の作業範囲は約 783 百万ピクセルから 1.34 十億ピクセルへ増えていました。この値は矩形面積の合計であり、fragment invocation 数、ビデオメモリの読み書きバイト数、GPU ハードウェアカウンターとは同じではありません。Metal の fast clear、attachment load/store、パス切り替えは引き続き GPU とドライバーの制御下にあります。

これで何もない領域が滑らかなままだった理由も説明できます。visibility culling は viewport の外にあるストロークを飛ばします。何もない領域では はほぼ 0 であり、密集した領域では が増え続けます。

0.85 ms で出た間違った答え

アプリにはすでに、1 point あたり 2 ピクセルで bake したページ全体のインクテクスチャがありました。書き込み、パン、ズームの間はこのテクスチャを直接表示し、現在の Apple Pencil ストロークだけを live vector として残す方法を試しました。操作が終わった後、追加の 1 フレームで高解像度のベクター結果を再描画します。

この方法は非常によく動きました。同じ密集領域を 300% ズームで表示すると、GPU 時間は 0.84–0.85 ms まで下がり、既存のチョークストローク数に応じて増えることもなくなりました。

実機での問題も同じくらい明確でした。300% ズームでは 1 point あたりおよそ 6 個の画面ピクセルが必要なのに、キャッシュが提供できるのは 2 個だけです。Apple Pencil が触れた瞬間、既存のストロークはすべて柔らかい低解像度画像になり、Pencil を離すと完全な鮮明さに戻りました。

テスターの言葉はこうでした。「書いていると、キャンバス全体がぼやけます。手を離すと、元の鮮明さに戻ります。」

この最適化は取り除きました。0.85 ms は測定上もっとも低い結果でしたが、受け入れられるチョークツールではありませんでした。既存のストロークは書き込みのフィードバックの一部であり、ペンを下ろした瞬間に鮮明さが変わってはいけません。

各チョークストロークを固有の矩形に制限する

最終的な修正では、ストロークごとの scratch とストロークごとの compositing を維持し、ピクセルの作業範囲だけを小さくしました。各ストロークには、すべてのスタンプ半径の union から求めた canvas bounding box がすでにあります。レンダラーはこの bounding box を現在の viewport の drawable 座標へ変換し、アンチエイリアス用の余白として 2 ピクセルを追加します。

同じ scissor 矩形を、scratch のクリア、ストロークの描画、結果のメインサーフェスへの合成という 3 つに使います。

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

同じロジックは 4096² のインクテクスチャへの baking と partial replay にも使われます。そのため、高ズーム表示と確定したインクレイヤーが別々のチョーク挙動を生みません。

ここで見落としやすい点が 2 つあります。

1 つ目。render pass の loadAction = .clear は attachment load の段階で実行され、rasterization scissor の制約を受けません。これを使い続けると、scratch テクスチャ全体がクリアされてしまいます。修正後の pass は .dontCare を使い、scissor 内で clear_fragment を描画します。この矩形はその後完全に書き込まれ、composite は同じ矩形だけを読むため、古い attachment の内容をロードする必要はありません。

2 つ目。各チョークストロークの composite が終わった後、外側の scissor を復元しなければなりません。この state restoration の行を省くと、後続のペン、画像、選択範囲まで前のチョークストロークの bounds によってクリップされ、ストロークや画像が欠けたように見えます。

チョークの grain は、矩形内のローカル UV ではなく、絶対的な canvas 座標から引き続きサンプルします。scissor を動かしても GPU が処理するピクセルが変わるだけで、各ピクセルが読む grain テクスチャの位置は変わりません。したがって隣接する矩形の間にテクスチャの継ぎ目は生じず、キャンバスをドラッグしても grain はずれません。

ピクセルのワークロードだけを考えると、新しい作業範囲はおよそ

です。ここで は、現在の画面上にある 番目のチョークストロークの axis-aligned bounding-box 面積です。render encoder の数は減っていませんが、各 clear と composite はストロークの画面上の bounding box に制限されます。

同じ色のチョークストロークをバッチ化しなかった理由

ページ上のチョークストロークの多くは同じ色と density を共有しているため、何十本ものストロークを一度に scratch へ描き、1 回だけ composite したくなります。そうすれば render pass はさらに減りますが、重なった領域での色と grain のセマンティクスが変わります。

意図的に単純化した例を考えます。あるピクセルで 2 本のストロークが同じ grain-gate 値 を共有し、body coverage が だとします。実際の shader では、gate は各ストロークの pressure depth にも依存します。この単純化したケースだけでも、batching が一般には等価でないことを示すには十分です。現在のストロークごとの compositing は

を生成します。一方、先に body をマージしてから 1 回の gate を適用すると

になります。

その差は です。2 本のストロークが重なり、grain gate が完全な 0 でも完全な 1 でもないとき、結果は異なります。直接バッチ化すると、交差部にチョークの粉が落ちる方法が変わってしまいます。

正確なバッチ化には、ストロークのピクセルが互いに重ならないことを証明するか、各ストロークに独立した atlas 領域を割り当てて元の順序で合成する必要があります。実機での最終受け入れでは scissor 方式を残したため、このラウンドでは atlas も、その管理の複雑さも導入しませんでした。

10 億ピクセルから数百万ピクセルへ

最終確認用デバイスでの測定:

シナリオ修正前正確な scissor
見えているチョークストローク 70 本、ズーム 300%、書き込みGPU 52–60 ms≈ 9–10 ms
≈ 121 本のチョークストローク、ズーム 300%GPU 77–80 ms13.7–15.6 ms
フレームごとの理論上の矩形範囲(scratch + composite)783 M–1.34 B ピクセル≈ 1.7 M–3 M ピクセル

デバイス文書の 3,452 本のストロークと 202,710 個のサンプル点を使って、クリッピングの境界も検証しました。ズーム 0.5×、1×、2×、3×、5×、8× で 186,408 個の viewport ケースを生成し、non-zero coverage を生成し得るすべての point sprite が計算された scissor 内に収まりました。この検証では canvas の端、viewport の端、さまざまな offset の組み合わせをカバーしました。

最終コードは、インタラクションの状態に応じて低解像度の LOD へ切り替えません。低いズームレベルではページ全体のインクテクスチャを表示し、高いズームレベルでは見えているストロークをベクターとして再描画します。同じしきい値側では、ペンを下ろしてもパンしても、既存のストロークを別の鮮明さのレベルに置き換えません。高ズームのベクター再描画中も、各チョークストロークの scratch clear と composite は、その画面上の bounding box だけを覆います。

GPU 時間だけでは鮮明さを捉えられなかった

GPU パフォーマンスの問題は、データ構造の中でもっとも目立つ量に比例するとは限りません。この場合、3,571 個の入力点は疑いやすい対象でした。しかしフレーム時間を決めていたのは、70 本のチョークストロークそれぞれが引き起こすフルスクリーンの作業と、render-pass の切り替えでした。

視覚的なセマンティクスも、利用できる最適化を制約しました。ストロークごとの scratch、元の compositing 順序、絶対的な canvas grain 座標は、自由に取り除けません。同じ色、同じ density はパラメータが一致するというだけで、重なった結果をマージできる証明にはなりません。

実機からのフィードバックは、GPU 時間がもっとも短いバージョンを退けました。「キャンバス全体がぼやける」という言葉が、測定だけでは表れなかったプロダクト上の制約を示しました。Apple Pencil が触れたとき、ユーザーは既存のストロークも見ているのです。

最終確認版は新しいインタラクションキャッシュレイヤーを導入せず、鮮明さも下げません。高ズームのベクター再描画は、各チョークストロークの作業をその画面上の bounding box に制限するだけです。LucasPad にもう一度ロードした後のフィードバックは、「とてもいいです。」でした。