Kimi K3 と Lulucat Notes のレンダリングパイプラインを再構築する
Core Graphics のタイルパイプラインを、6つの小さなステップで Metal のポイントスプライトパイプラインに置き換え、それぞれを実機の iPad で検証しました。コードは Fireworks 上の Kimi K3 とペアプログラミングで書きました。
先週、Lulucat Notes で投げ縄選択をドラッグすると、目に見える波が発生していた。同じフレーム内で、一部の画面タイルは選択範囲を新しい位置に表示し、別のタイルは古い位置のまま残る、という現象だ。レンダリングパイプライン全体 — Core Graphics のビットマップと CATiledLayer — を Metal に置き換えた。6つの小さなステップで進め、各ステップを実機の iPad で検証してから次に進んだ。
コードは、Fireworks 上で動く Moonshot のオープンモデル Kimi K3 とペアプログラミングで書いた。人間が進行を担い、決定し、テストした。モデルがほぼすべての行を書いた。
パイプライン
旧パイプラインには、徐々に乖離していった2種類の描画があった。ストロークは低コストで表示するためにビットマップに焼き込まれ、タイルがより詳細を必要とするときにはベクターとして再描画された。新しいパイプラインのアイデアはただ一つだ。インクのようなものはすべてポイントスプライトである。ペンのストローク、ハイライターの払い、消しゴムのダブは、同じ32バイトの頂点 — 位置、直径、色 — であり、ストロークの弧長に沿って1ポイント間隔で並ぶ、GPU でラスタライズされた円として、同じシェーダーペアで描画される。
![]()
1ポイント間隔では、円の連なりは数学的に完全なカプセルから約0.075ポイント — 当社のキャンバス密度ではピクセル5分の1 — ほど乖離する。引き換えに、3つのツールが1つのコードパスに畳み込まれ、GPU は得意なことをこなす。
そのアイデアを中心に、アーキテクチャはシンプルだ。確定したインクは単一の 4096² テクスチャに存在する。UIScrollView は残るが、純粋なジェスチャーエンジンに格下げされた。その contentOffset と zoomScale が毎フレーム 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つの正しいラスタライザは、アンチエイリアスされたエッジに沿って数グレーレベルだけ正当に不一致する。ゲートは構造的なものだ。インクの欠落なし、オフセットなし、色のドリフトなし、そしてインクから離れた場所での大きな差分なし。
![]()
このハーネスは、オフラインレンダラーの構築中に遭遇した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つのアイデアになり、そこに至ったプロセスは残り続ける。