Lulucat

Kimi K3 と Lulucat Notes のレンダリングパイプラインを再構築する

Gaoge ZhangGaoge Zhang

Core Graphics のタイルパイプラインを、6つの小さなステップで Metal のポイントスプライトパイプラインに置き換え、それぞれを実機の iPad で検証しました。コードは Fireworks 上の Kimi K3 とペアプログラミングで書きました。

更新日

先週、Lulucat Notes で投げ縄選択をドラッグすると、目に見える波が発生していた。同じフレーム内で、一部の画面タイルは選択範囲を新しい位置に表示し、別のタイルは古い位置のまま残る、という現象だ。レンダリングパイプライン全体 — Core Graphics のビットマップと CATiledLayer — を Metal に置き換えた。6つの小さなステップで進め、各ステップを実機の iPad で検証してから次に進んだ。

コードは、Fireworks 上で動く Moonshot のオープンモデル Kimi K3 とペアプログラミングで書いた。人間が進行を担い、決定し、テストした。モデルがほぼすべての行を書いた。

パイプライン

旧パイプラインには、徐々に乖離していった2種類の描画があった。ストロークは低コストで表示するためにビットマップに焼き込まれ、タイルがより詳細を必要とするときにはベクターとして再描画された。新しいパイプラインのアイデアはただ一つだ。インクのようなものはすべてポイントスプライトである。ペンのストローク、ハイライターの払い、消しゴムのダブは、同じ32バイトの頂点 — 位置、直径、色 — であり、ストロークの弧長に沿って1ポイント間隔で並ぶ、GPU でラスタライズされた円として、同じシェーダーペアで描画される。

同じ曲線ストロークの3つのパネル:入力タッチポイント、弧長に沿って曲線上に間隔を空けた円形のスタンプ、合成された塗りつぶしストローク。

1ポイント間隔では、円の連なりは数学的に完全なカプセルから約0.075ポイント — 当社のキャンバス密度ではピクセル5分の1 — ほど乖離する。引き換えに、3つのツールが1つのコードパスに畳み込まれ、GPU は得意なことをこなす。

そのアイデアを中心に、アーキテクチャはシンプルだ。確定したインクは単一の 4096² テクスチャに存在する。UIScrollView は残るが、純粋なジェスチャーエンジンに格下げされた。その contentOffsetzoomScale が毎フレーム viewport ユニフォームに供給されるため、パンとズームは何も書き込まない。各フレームは5つの描画だ。

flowchart TB
    subgraph frame["Every frame: five draws"]
        direction TB
        paper["1 · paper blit"] --> ink["2 · committed ink"]
        ink --> live["3 · live stroke"]
        live --> sel["4 · selection"]
        sel --> dash["5 · lasso dashes"]
    end
    commit["stroke commit<br/>append stamps"] --> tex[("ink texture<br/>4096² render target")]
    replay["regional replay<br/>erase · delete · move · undo"] --> tex
    tex -. "sampled or re-drawn" .-> ink

編集はテクスチャに直接書き込む。ストロークを確定すると、そのスタンプが追加される。消去、削除、移動、取り消しは、シザー矩形の背後で影響を受けた領域をリプレイする。領域をクリアし、それと交差するストロークを再描画して、完了だ。部分消去は、前の投稿の所有権セマンティクスを保つ — つまり、ある消去はインクを除去するストロークに属する — ために、各消去されたストロークをスクラッチテクスチャに描画し、destination-out ブレンディング()で自身の消去パスを減算し、結果を合成し戻す。このスクラッチによる分離が、消しゴムが用紙や隣接するストロークを食い破るのを防ぐ。

すべての発端となった選択範囲も、いまはポイントスプライトとして描画される。ドラッグすると1つのユニフォームオフセットが更新される。テクスチャへの書き込みはゼロ、タイルの無効化もゼロ — 波は構造的に消え去ったのであって、緩和されたのではない。

ゲートキーパー:ピクセル差分

Core Graphics のレンダラーを削除したわけではない。オフラインの参照実装に格下げし、Metal のすべての変更は、実機でキャプチャした実際のストロークデータに対してピクセル比較をパスしなければならない。受け入れ基準は「同一のピクセル」ではない。2つの正しいラスタライザは、アンチエイリアスされたエッジに沿って数グレーレベルだけ正当に不一致する。ゲートは構造的なものだ。インクの欠落なし、オフセットなし、色のドリフトなし、そしてインクから離れた場所での大きな差分なし。

同じ手書きノートの3つの切り抜き:Core Graphics でレンダリング、Metal のポイントスプライトでレンダリング、そして6倍に増幅したピクセル差分。ストロークのエッジに沿ったかすかな輪郭だけが現れている。

このハーネスは、オフラインレンダラーの構築中に遭遇した5つのバグのうち4つを捉えた。いずれも出力をピクセルレベルまで切り抜いて診断した。Swift/Metal の構造体ストライドの不一致(28バイト対32バイト。Metal が float4 を16に整列させるため — 画面は色のブロックで埋まった)、フラグメントシェーダーで varying として読めない [[point_size]](すべてのスタンプが四角になった)、1つのコマンドバッファ上に2つのレンダーエンコーダが共存した(すべて真っ黒)、そして素早いストロークの最初の1ミリが見えなくなる開始スタンプの欠落、である。

6つのステップ、一括りの書き直しではない

移行計画は、独立して出荷可能な6つのステップだった。ピクセル差分をパスするオフラインレンダラー。視覚的変更ゼロの表示シェル。GPU 上のライブストローク。GPU 上の選択。テクスチャに直接書き込むミューテーション。高ズーム時のベクター再描画(ステップ2に折り込まれた。「視覚的変更ゼロ」がそれを要求したため)。各ステップは、シミュレータでもスクリーンショットの差分でもなく、卓上の iPad Pro で人間が書き、消し、ズームし、ドラッグして終わった。

デバイスは、すべての自動チェックが見逃した3つのバグを捉えた。100%ズームを超えると、ストロークが2回描画された — 下にソフトなテクスチャ、上に鋭いスプライト — それはかすかなぼかしとして読め、人間は数秒で気づいた。ストロークを確定すると1フレーム点滅した。古いオーバーレイがテクスチャ更新と同期せずクロスフェードアウトしたためだ。そして170%ズームを超えると、すべてのノートが消えた。可視性カリングの矩形が、スケールされた座標空間で contentOffset を使っていたため、ズームするにつれてストロークから離れていったのだ。この3つはすべて1関数につき1行の修正であり、事前に書けたであろうテストでは、どれも検出できなかった。なぜなら、どこを見ればよいか知らなかったからだ。それが UI ファーストのコンシューマアプリで人間をループに残す理由だ。

Kimi K3 を使って働くとはどういうことか

まず速い。「話し合い、書き、ビルドし、インストールし、見る」のループが数分で回り、素早く答えるモデルは1日に回せるループ数を変える。

次に、オーバーエンジニアリングしない。このコードベースは明示的なハウスルールで動いている — ローンチ前の後方互換性の足場はなし、複雑さはデバイスが必要と証明したときだけ — そして K3 は、それを思い出させることなく守る。「後のために」空間インデックスを追加せず、すべての呼び出しを防御的チェックで包まず、投機的に抽象化しなかった。プロンプトを入力する感覚は、ハウスルールを読み、それを本当に信じている有能な同僚と仕事をしているようだ。

3つ目に、ツールを与えると喜んで使う。画像ユーティリティ — 表示、ピクセル領域への切り抜き、リサイズ — を繋ぎ込むと、モデルは上記の5つのハーネスバグを診断するために、自らのレンダラー出力を能動的に切り抜き始めた。ツールがあることで、見ることを思い出させた。

もう半面:K3 はこの物語のバグの大半を書いた。ノートを消失させた座標空間のバグも含めて。その限界は本物だ。作業を安全にしたのは、モデルが正しいことでは決してなかった。レンダリングのドリフトを捉えるハーネスと、感触を捉える人間だった。それでも、日々の仕事では、我々が併用するフロンティアのクローズドモデル — Opus クラスのシステム — と確実には区別できなかった。いくつかの点では明らかに優れていた。速く、コードベースを防御的設計で膨らませる傾向がはるかに少ない。

これからどう作りたいか

大規模な仕様に基づくエージェント開発はもう終わりにした — モデルに大きな仕様を渡し、出来上がったものをそのまま受け入れるスタイルだ。その失敗モードは悪いコードではない。誰にも理解されないコードだ。

ここで効いたもの、そして我々が維持するもの:小さなステップ。それぞれ始まる前に話し合い、構築される前に人間が理解し、実際に動くデバイスで検証する。モデルの役割は、素早く、正確で、不確実性に正直であること。人間の役割は判断、センス、そして e2e 検証 — 特に UI ファーストのコンシューマソフトウェアでは、仕様は「正しい」がどう感じられるかを記述できない。Fireworks 上の Kimi K3 は、まさにこのループにうまく適合することが分かった。ループをタイトに保つのに十分速く、ステップを小さく整然と保つのに十分賢い。

波は消え、パイプラインは2つではなく1つのアイデアになり、そこに至ったプロセスは残り続ける。