Skip to content

TortoiseUI: batch same-color, same-width committed strokes (#37) - #38

Merged
temoki merged 2 commits into
mainfrom
fix/37-batch-committed-strokes
Jul 29, 2026
Merged

TortoiseUI: batch same-color, same-width committed strokes (#37)#38
temoki merged 2 commits into
mainfrom
fix/37-batch-committed-strokes

Conversation

@temoki

@temoki temoki commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

CanvasRenderer.drawElements が確定要素 1 つにつき Path を 1 つ作り ctx.stroke を 1 回呼んでいたため、CommittedLayer の再描画コストが「画面にどれだけ映っているか」と無関係に 要素あたり約 0.37µs かかっていた。10,000 ストロークで 4.17ms — 120Hz のフレーム予算の約半分を、コマンドがコミットされるたびに消費していた。

同じ色・同じ太さの連続するストロークを 1 本の複数サブパス Path に集約し、ctx.stroke を 1 回だけ呼ぶようにした。丸キャップはサブパスごとに付くため描画結果は変わらない。

プログラム 変更前 変更後
渦巻き 1,000 0.44ms 0.18ms
渦巻き 4,000 1.68ms 0.36ms
渦巻き 10,000 4.17ms 0.64ms(6.5x)
全ストローク色違い 10,000(バッチ不可) 3.96ms 3.95ms
半透明 10,000(ガードで除外) 3.82ms 3.87ms

10,000 ストロークで 0.64ms(受け入れ条件の 1ms 未満を達成)。バッチが一切効かないプログラムでも、集約ループのオーバーヘッドによる劣化はない。計測は issue と同じハーネス(ImageRenderer の 1 パス = CommittedLayer 1 回の再描画、best-of-5, release, scale 2, 400×400, Apple M4)。

issue の仕様案からの変更点: 半透明ストロークは除外

color.alpha < 1 のストロークはバッチしない。半透明の線分が重なる箇所(丸キャップの継ぎ目すべてと自己交差すべて)では、集約すると合成が 2 回から 1 回に変わり、AA のズレではなく明確に見た目が変わる。SVG レンダラは 1 ストローク = 1 個の <line> を出力するため、バッチすると両レンダラの出力も乖離する。

これを守るため translucentOverlaps シナリオを追加した。ゴールデンは バッチ実装前のレンダラで記録 し、バッチ実装を入れた状態でも通ることを確認しているので、このゴールデンは構成上レンダラ非依存になっている。ガードを外すと 7,764 ピクセルが delta 32 超で変化してテストが落ちることも確認済み(AA のみのケースは 12〜29 ピクセル)。

.fill / .arcStroke / .dot は issue の方針どおり現状維持。要素順・z-order(および fillInsertionIndex)は不変。

ゴールデンの再記録

CLAUDE.md の手順どおり両方録り直した。

  • SVG ゴールデン: バイト単位で同一(SVG レンダラは未変更)。新規シナリオの 1 ファイルのみ追加。
  • canvas PNG: 10 件が変化。各ファイルを変更前のゴールデンとピクセル単位で比較し、差異ピクセルは 1% 未満、delta 32 超は最大 183 ピクセル、delta 128 超は 0、すべてストロークの AA エッジに限定されることを確認。差分マップと変更前後の画像を目視したが区別できない。実際に precision: 0.995 を超えていたのは clearDuringFillteleportAndHome の 2 件のみ。

受け入れ条件

  • 10,000 ストロークの CommittedLayer 再描画が 1ms 未満 → 0.64ms
  • 色や太さを途中で変えるプログラムで描画順・z-order が変わらない(penStyles / backgroundAndColors ゴールデン)
  • 半透明 fill と stroke が混在するプログラムの見た目が変わらない(translucentOverlaps シナリオを追加)
  • ゴールデンを再記録し、差分が AA のみであることを目視確認
  • seek(to:) / step() / clear() 後の表示が正しい(レンダラは model.elements の純粋関数であり、既存の CanvasModel テストと clearAndRedraw / clearDuringFill ゴールデンでカバー)

未対応(別途ご判断ください)

issue の「参考」にあった「ViewportMode は性能の要因ではない」という知見の README/DocC への記載は本 PR では見送った。CHANGELOG には計測の要点を記載済み。

Related issue

Fixes #37

Checklist

  • swift test passes locally(114 tests 全通過)
  • xcrun swift-format lint --recursive --strict Sources Tests passes
  • Added/updated tests for new behavior(translucentOverlaps シナリオを追加)
  • Updated CHANGELOG.md
  • Golden snapshots: re-recorded both SVG and PNG goldens in the same commit and visually inspected them

temoki added 2 commits July 29, 2026 21:28
The environment default (EnvironmentValues.tortoiseViewport) is .autoFit,
matching the DocC article, but the enum's doc comment still marked
scaleToFit as the default.
CanvasRenderer.drawElements built one Path and issued one ctx.stroke per
committed segment, so a committed-layer redraw cost ~0.37us per element no
matter how much of it was on screen — 4.17ms for a 10,000-stroke drawing,
half of a 120Hz frame budget, paid again on every command commit.
Consecutive strokes sharing color and width now merge into a single
multi-subpath Path drawn with one ctx.stroke; round caps apply per subpath,
so the drawing is unchanged. 10,000 strokes: 4.17ms -> 0.64ms (6.5x), 4,000:
1.68ms -> 0.36ms. Programs where no run is batchable (a new color per
stroke) show no regression from the merge loop.

Translucent strokes (color.alpha < 1) are excluded from the merge:
overlapping segments have to blend once per stroke to match the SVG
renderer's one <line> per stroke, and batching would blend the overlap only
once. The new translucentOverlaps scenario guards this — its golden was
recorded with the pre-batching renderer and still passes.

Canvas goldens re-recorded: batching rasterizes stroke overlaps once instead
of blending twice, so antialiased edges shift. Verified per file against the
previous goldens — under 1% of pixels differ, at most 183 by more than
delta 32 and none by more than 128, all confined to stroke edges and
visually indistinguishable. SVG goldens are byte-identical.
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.89%. Comparing base (519e774) to head (e66ad86).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #38      +/-   ##
==========================================
+ Coverage   91.70%   91.89%   +0.19%     
==========================================
  Files          15       15              
  Lines         892      913      +21     
==========================================
+ Hits          818      839      +21     
  Misses         74       74              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@temoki
temoki merged commit 97cd1fd into main Jul 29, 2026
6 checks passed
@temoki
temoki deleted the fix/37-batch-committed-strokes branch July 29, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TortoiseUI: 確定要素の描画を同色・同幅ストロークでバッチする(コミット時 O(n) の定数を 6 倍改善)

1 participant