なぜ私たちは三つのハイライター用ジオメトリエンジンをスタンプに置き換えたのか
実際の Apple Pencil データで合成テストが見逃した欠陥が明らかになり、Lulucat Notes は三つのアウトラインエンジンから MaLiang のスタンプモデルへ移行しました。
Lulucat Notes は、一般的なハイライター・レンダラーから始まりました。サンプリングしたパスの周囲に幾何学的なアウトラインを作り、そのアウトラインを半透明の色で一度だけ塗りつぶします。塗りつぶしを一度にすることで、半透明のセグメントが重なる場所に現れる濃い帯を防げます。同時に、すべての接続部、自己交差、キャップ、短いストロークを同じジオメトリ問題の中で扱うことになります。
カスタムジオメトリは三世代にわたり、それぞれ一種類の欠陥を取り除く一方で、別の欠陥を露呈させました。現在提供しているレンダラーは、Harley-xk による MIT ライセンスのオープンソース描画フレームワーク MaLiang を基にした、柔らかな円形スタンプを使います。アウトラインに関する失敗のカテゴリーを丸ごと取り除く代わりに、自己交差が濃くなることと、筆圧に応じて端が細くなることは受け入れました。

デバイスの元のスクリーンショットを228%で拡大。複数のストロークの両端に小さなフックが現れています。
実際の手書きでは、合成シーンに現れなかったフックが生じた
合成シーンでは、直線、弧、短いタップ、引き返し、筆圧の変化、鋭い方向転換を網羅しており、どれもきれいに描画できていました。それでも実際の中国語の手書きでは、多くのストロークの始点と終点に、長さ1–3ポイントほどのフックが生じました。
そこでアプリに保存ボタンを追加し、完全なストロークを29本記録しました。すべてのサンプルについて、位置、半径、筆圧、方位角、高度、タイムスタンプ、UIKitの推定フラグを保存しました。オフラインプログラムでそのJSONをデコードし、アプリが使うものと同じ Stroke.swift、StrokeRenderer.swift、HighlighterStrokeBuilder.swift をコンパイルしました。出力はフックも含め、デバイスのスクリーンショットとストローク単位で一致しました。
オフラインレンダラーが記録済みの入力だけで欠陥を再現したため、原因は表示の挙動ではなく、サンプルとジオメトリにありました。これで、レンダラーに入った正確なサンプルを読み取れるようになりました。
最初の仮説では、ペンが接地・離脱する瞬間の推定された Pencil の向きが原因だと考えました。しかしデータはそれを否定しました。29本すべてのストロークで、最初と最後の八つのサンプル内における方位角の変化は最大でも0.09ラジアン、高度の変化は最大でも0.02ラジアンでした。その iPad では推定フラグもすべて false でした。向きは安定したまま、筆圧と方向だけが大きく変化していました。
筆圧の下限は、データにある明確な空白から決まった
記録したすべてのストロークで、最後のサンプルの筆圧は 0.000 でした。書いている最中のサンプルは 0.01 以上だった一方、Pencilが面を離れた後に記録されたサンプルは 0.002 以下でした。空中にあるサンプルは2–7ポイント続くことが多く、0.8–9.0ポイント移動し、その方向は書かれたストロークからおよそ90度離れるように曲がることもありました。
ストローク2がその仕組みを示します。本体は143度から180度の間で終わっていました。その後、低筆圧の尾部が−90度から−67度まで向きを変え、さらに8.5ポイント移動しました。アウトライン・レンダラーは最後の空中サンプルを正式な終点として扱ったため、その点に向かって細いジオメトリを伸ばしていました。
測定した筆圧には、書いている動きと空中での動きの間にほぼ一桁分の差があります。そこで、その空白の中にカットオフを置きました。
private static let pressureFloor: CGFloat = 0.005
削除するのは両端にある低筆圧のサンプルだけです。ストロークの途中にある低筆圧のサンプルは、ストロークの一部として残ります。このルールは測定されたイベントの境界に従い、書かれたパスを編集しません。

トリミング前は、レンダラーが空中のサンプルにキャップを固定します。緑の点は生のサンプル、赤い線は生成されたアウトラインです。

0.005未満のサンプルをトリミングすると、キャップは最後に書かれた点で終わります。この筆圧の下限は、現在のスタンプ・レンダラーにも残っています。
この下限により、空中で生じる長いフックは取り除かれました。筆圧が 0.005 を上回っていた短い接地時のドラッグや離脱時の弧は、書かれた動きなので残りました。こうした動きをアウトラインモデルの中で扱ったことが、次のジオメトリ改修につながりました。
三つのジオメトリエンジンは欠陥を移動させた
第一世代:一つのアウトライン、一回の塗りつぶし
最初の筆圧対応エンジンは、よくあるパイプラインに従っていました。中心線を平滑化し、左右のオフセットを計算し、それらを一つのポリゴンに結合して、そのポリゴンを一度だけ塗りつぶします。ペン先は Apple Pencil の方位角と高度で動く楕円だったため、断面は書く方向に応じて変化しました。短いタップには別の形状を使い、平らなキャップには小さな非対称性を加えて、ワインディングがゼロになるポケットを避けました。
一回の塗りつぶしでストローク内部の不透明度の蓄積は解決できましたが、どの方向転換でも有効なポリゴンが必要でした。接地時のドラッグはキャップと本体の間にくさび形を作りました。引き返しでは白い三日月形のポケットが生じることがありました。鋭い方向転換では内側のアウトラインが自己交差し、塗られない切り欠きが残ることがありました。
第二世代:端の近くでアウトラインを修復する
第二世代のエンジンはアウトラインを維持しながら、局所的な修復を追加しました。引き返しのトリミング、初期の推定プロパティのダンピング、終点の直線化、一時的な方向のトリミング、そして小さなキャップ延長です。それぞれのパッチは再現した失敗に対応していました。しかし組み合わせると、終点の挙動はいくつものしきい値とウィンドウに依存するようになりました。
実際の書字では、そのウィンドウの外側にあるケースが現れました。固定した24ポイントの直線化ウィンドウは、短いストロークに意図的に入れたフックを消しました。適応型ウィンドウならフックを残せましたが、離脱時の弧が目に見える角になることはありました。方向を考慮したトリミングは一時的な動きをさらに取り除きましたが、記録したあるストロークでは、本当に曲がった始点から約2.5ポイントを捨てることになりました。
各パッチは対象のケースを修正しました。それでも土台のモデルには、ノイズを含む曲線的な入力に対して有効であり続ける硬いアウトラインが必要でした。
第三世代:クリップした正方形キャップによるカバレッジの合成
第三世代のエンジンは、一つの輪郭を構築するのをやめました。グレースケールのマスクに、各セグメントには台形を、各内部サンプルには円盤を描き、それらのカバレッジを合成してから、ハイライターの色を一度だけ適用します。引き返しによる三日月、自己交差による濃色化、ワインディングがゼロの穴は消えました。レンダラーがポリゴンのワインディングに依存しなくなったためです。
合成すると端が丸くなったため、二つの終点半平面に対してカバレッジをクリップし、正方形キャップを戻しました。このクリップは一定筆圧の直線を修正しましたが、より深刻な欠陥を導入しました。曲がったストロークの本体は、正当に終点半平面を横切ることがあります。その場合、キャップのクリップがストロークの中央の一部まで削ってしまいます。ライブ入力中は終点の方向が変化し続けるため、クリップされる領域が動き、ストロークがちらつきました。

グローバルなキャップ制約が、曲がったストロークの本体を横切っています。

終点から半幅以内にあるプリミティブだけにキャップのクリップを限定すると、このストロークが復元されます。
弧長ゲートによってキャップのクリップは局所的になり、記録したすべてのケースを修正できました。それでも極端な合成フックでは、低筆圧の先端がクリップされたキャップに接する場所に小さな白い切り欠きが残りました。第三世代のエンジンもまた、一つのジオメトリの失敗を別の失敗と交換したのです。
MaLiangは硬い境界を取り除く
MaLiang は、一つの閉じたアウトラインではなく、テクスチャスタンプを繰り返してストロークを作ります。パスジェネレーターは、隣接するサンプルの中点を通る二次 Bézier セグメントを使い、ラインレンダラーはそのパス上に一定間隔でスタンプを配置します。ストロークに沿って、スタンプのサイズ、回転、色、不透明度はすべて変化させられます。
柔らかなスタンプには、独立したキャップ、接続部、ワインディング規則がありません。方向が急に変わっても、重なり合うマークの列として残ります。モデルに終点クリップがないため、引き返しで空のポリゴンポケットができることはなく、終点半平面がストロークの中央を切ることもありません。
私たちが採用したのはコードではなくモデルです。現在の HighlighterStrokeBuilder は四つの処理を行います。
- 筆圧が
0.005未満の終点サンプルだけを削除する。 - 中点を通る二次 Bézier セグメントで位置を平滑化する。
- 弧長ごとに円形スタンプを一つ配置する。
- 正規化した筆圧からスタンプの直径を決め、source-over アルファでスタンプを合成する。
ビルダーは686行から190行になりました。方位角、高度、アウトライン構築、合成マスク、終点方向ウィンドウ、キャップ延長、キャップクリップはすべてハイライターのパスからなくなりました。入力モデルが対応しているため Pencil の向きは引き続き記録しますが、ハイライター自体はもうそれらのフィールドを読みません。
MaLiangの筆圧式にはデバイスのキャリブレーションが必要だった
MaLiangは次と同等の式でスタンプのサイズを決めます。
この式は、force が0から1までの範囲のうち有効な部分を使うことを前提にしています。しかし私たちのデバイスデータはそうなっていませんでした。約3,400個の Apple Pencil サンプル全体で、筆圧の中央値は 0.047、最大値は 0.178 でした。この値をそのまま式に入れると、通常の書字が公称ブラシサイズよりはるかに細くなりました。
そこで、観測した中央値を基準に正規化し、指数を適用してから結果をクランプします。
書字時の中央値の筆圧は、現在では公称12ポイントの直径に対応します。非常に軽い接触でも公称サイズの35%で見える状態を保ち、強い筆圧では120%で成長を止めます。
このキャリブレーションは MaLiang ではなく、入力デバイスとブラシの挙動に属します。自分たちの筆圧分布を測らずに元の式をコピーしていたら、アーキテクチャは維持できても、ブラシは正しくなりませんでした。
スタンプごとのアルファは既知の重なり数から決まった
MaLiangは、重なり合う半透明スタンプを経験的な式 alpha ÷ overlapping × 2.5 で補正します。私たちのブラシは公称直径12ポイント、スタンプ間隔1ポイントで固定されているため、中心線には約12層が重なります。このケースでは重なり数が分かっているので、補正値を推定ではなく解くことができます。
目標とするストロークの不透明度
したがって、一つのスタンプに必要なのは

記録したデバイスのパスを再生する最終レンダラー。フック、斜めの切断、白いポケットはありません。
受け入れた二つのコスト
自己交差は濃くなります。source-over 合成は、同じ領域を前に通過したときに置かれたスタンプも含め、すべてのスタンプを数えます。カバレッジ合成エンジンはこれを避けていました。重なりが濃くなることは予測可能で、ストロークの形を保てるため、私たちは受け入れました。
終点は筆圧とともに細くなります。実際に Pencil を離すと筆圧が下がり、スタンプの直径も小さくなるため、ストロークは全幅の正方形キャップで終わらず、先細りになります。私たちは三つすべてのジオメトリエンジンで、その正方形キャップを要件として扱っていました。それを手放すことで、上記の失敗のいくつかを生んでいたクリップと終点処理の仕組みを取り除けたため、先細りの端を受け入れました。
したがって、提供するモデルは以前のモデルほど多くを行いません。測定された書字イベントを保持し、デバイスのキャリブレーション後の筆圧に応答し、グローバルに有効なアウトラインを必要とせずに曲線や自己交差するパスを描画します。チゼル形状のペン先、完全な正方形キャップ、生のサンプル間にある正確なポリラインは、その代わりに手放した機能です。
筆圧の下限は、他のプロジェクトへ最も直接的に移せる知見です。値 0.005 は実際の入力にある二つのクラスターの間の空白から来ています。書字は 0.01 以上、空中での追跡は 0.002 以下でした。同じ記録済みストロークによって、各ジオメトリ修正がどこで失敗したかも分かりました。その失敗がスタンプモデルと、そこに伴う二つのコストにつながりました。