Neuaufbau der Lulucat-Notes-Rendering-Pipeline mit Kimi K3
Unsere Core-Graphics-Tile-Pipeline wurde in sechs kleinen Schritten zu einer Metal-Point-Sprite-Pipeline, jeder auf einem echten iPad verifiziert. Der Code entstand im Pair-Programming mit Kimi K3 auf Fireworks.
Letzte Woche erzeugte das Ziehen einer Lasso-Auswahl über Lulucat Notes eine sichtbare Welle: Einige Bildschirm-Tiles zeigten die Auswahl an ihrer neuen Position, während andere im selben Frame noch die alte zeigten. Wir haben die gesamte Rendering-Pipeline — Core-Graphics-Bitmap plus CATiledLayer — in sechs kleinen Schritten durch Metal ersetzt, jeden auf einem echten iPad verifiziert, bevor der nächste begann.
Der Code entstand im Pair-Programming mit Kimi K3, Moonshots offenem Modell, das auf Fireworks läuft. Der Mensch steuerte, entschied und testete; das Modell schrieb fast jede Zeile.
Die Pipeline
Die alte Pipeline hatte zwei Arten des Zeichnens, die langsam auseinanderdrifteten: Striche wurden für eine günstige Darstellung in eine Bitmap gebacken und als Vektoren neu gezeichnet, wann immer Tiles mehr Detail brauchten. Die neue Pipeline hat genau eine Idee: alles Tintenartige ist ein Point Sprite. Ein Stiftstrich, ein Textmarker-Schwung und ein Radierer-Stupser sind derselbe 32-Byte-Vertex — Position, Durchmesser, Farbe —, gezeichnet von demselben Shader-Paar als GPU-rasterte Kreise im Abstand eines Punkts entlang der Bogenlänge des Strichs.
![]()
Bei einem Abstand von einem Punkt weicht eine Kette von Kreisen um etwa 0.075 Punkte von einer mathematisch perfekten Kapsel ab — ein Fünftel eines Pixels bei unserer Canvas-Dichte. Dafür fallen drei Werkzeuge auf einen Code-Pfad zusammen, und die GPU macht, was sie am besten kann.
Um diese Idee herum ist die Architektur einfach. Committete Tinte lebt in einer einzigen 4096²-Textur. Das UIScrollView überlebt, degradiert zu einer reinen Gesten-Engine: Sein contentOffset und zoomScale speisen jedes Frame ein Viewport-Uniform, sodass Schwenken und Zoomen nichts schreiben. Jedes Frame besteht aus fünf Draws:
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
Bearbeitungen schreiben die Textur direkt. Das Committen eines Strichs hängt seine Stempel an. Löschen, Entfernen, Verschieben und Undo spielen die betroffene Region hinter einem Scissor-Rechteck neu ab: Region löschen, die sie kreuzenden Striche neu zeichnen, fertig. Partielles Löschen bewahrt die Ownership-Semantik aus dem vorherigen Post — eine Löschung gehört zu dem Strich, dem sie Tinte entzieht —, indem jeder gelöschte Strich in eine Scratch-Textur gezeichnet wird, seine eigenen Löschpfade mit Destination-Out-Blending abzieht (
Die Auswahl, die das alles ausgelöst hat, wird jetzt ebenfalls als Point Sprites gezeichnet. Sie zu ziehen, aktualisiert einen einzigen Uniform-Offset. Keine Textur-Schreibvorgänge, keine Tile-Invalidierungen — die Welle ist strukturell weg, nicht nur gemildert.
Der Gatekeeper: ein Pixel-Diff
Wir haben den Core-Graphics-Renderer nicht gelöscht. Wir haben ihn zu einer Offline-Referenzimplementierung degradiert, und jede Metal-Änderung muss einen Pixel-Vergleich gegen ihn auf echten Strichdaten bestehen, die auf dem Gerät erfasst wurden. Das Akzeptanzkriterium ist nicht „identische Pixel“ — zwei korrekte Rasterizer dürfen legitim um ein paar Graustufen an Kanten mit Anti-Aliasing abweichen. Das Gate ist strukturell: keine fehlende Tinte, kein Versatz, kein Farbdrift und keine große Differenz irgendwo abseits der Tinte.
![]()
Dieser Harness hat vier der fünf Bugs gefunden, auf die wir beim Bau des Offline-Renderers stießen, alle diagnostiziert durch das Zuschneiden des Outputs auf Pixelebene: ein Swift-/Metal-Struct-Stride-Mismatch (28 Byte versus 32, weil Metal float4 an 16 ausrichtet — der Bildschirm füllte sich mit Farbblöcken), [[point_size]], das als Varying im Fragment-Shader nicht lesbar war (jeder Stempel kam quadratisch heraus), zwei Render-Encoder, die auf einem Command-Buffer koexistierten (alles schwarz), und ein fehlender Start-Dab, der den ersten Millimeter schneller Striche unsichtbar ließ.
Sechs Schritte statt eines kompletten Umbaus
Der Migrationsplan war sechs unabhängig auslieferbare Schritte: Offline-Renderer, der den Pixel-Diff besteht; Display-Shell ohne visuelle Änderung; Live-Strich auf der GPU; Auswahl auf der GPU; Mutationen, die die Textur direkt schreiben; Vektor-Neuzeichnen bei hohem Zoom (in Schritt zwei integriert, weil „null visuelle Änderung“ es verlangte). Jeder Schritt endete damit, dass ein Mensch auf dem iPad Pro auf dem Schreibtisch schrieb, radierte, zoomte und zog — kein Simulator und kein Screenshot-Diff.
Das Gerät hat drei Bugs gefunden, die jeder automatisierte Check verpasst hat. Oberhalb von 100% Zoom wurden Striche doppelt gezeichnet — weiche Textur darunter, scharfe Sprites obendrauf —, was als ein leichtes Blur wirkte, das der Mensch in Sekunden bemerkte. Das Committen eines Strichs flackerte für ein Frame, weil das alte Overlay beim Cross-Fade asynchron zur Textur-Aktualisierung lief. Und oberhalb von 170% Zoom verschwand jede Notiz: Das Visibility-Culling-Rechteck nutzte contentOffset in seinem skalierten Koordinatenraum, driftete also beim Zoomen von den Strichen weg. Alle drei waren einzeilige Änderungen in jeweils einer Funktion, und keiner von ihnen existierte in irgendeinem Test, den wir vorher hätten schreiben können, weil wir nicht wussten, worauf wir achten sollten. Bei einer UI-first-Consumer-App bleibt der Mensch deshalb im Loop.
Wie es ist, mit Kimi K3 zu arbeiten
Schnell, zuallererst. Der Loop aus „besprechen, schreiben, bauen, installieren, ansehen“ lief in Minuten ab, und ein Modell, das schnell antwortet, verändert, wie viele Loops man sich an einem Tag leisten kann.
Zweitens over-engineert es nicht. Diese Codebasis läuft nach expliziten House Rules — kein Backward-Compatibility-Scaffolding vor dem Launch, Komplexität nur, wenn ein Gerät sie als nötig erweist — und K3 folgt ihnen, ohne daran erinnert zu werden. Es hat keine räumlichen Indizes „für später“ hinzugefügt, hat nicht jeden Aufruf in defensive Prüfungen gewickelt und hat nicht spekulativ abstrahiert. Es zu prompten fühlt sich an wie die Arbeit mit einem kompetenten Kollegen, der die House Rules gelesen hat und sie tatsächlich glaubt.
Drittens: Gib ihm Werkzeuge, und es nutzt sie eifrig. Wir haben Image-Utilities verdrahtet — ansehen, auf eine Pixel-Region zuschneiden, skalieren — und das Modell fing an, proaktiv seinen eigenen Renderer-Output zuzuschneiden, um die fünf Harness-Bugs oben zu diagnostizieren. Das Vorhandensein des Werkzeugs erinnerte es daran, hinzusehen.
Die andere Hälfte: K3 hat die meisten Bugs in dieser Geschichte geschrieben, einschließlich demjenigen aus dem Koordinatenraum, der Notizen verschwinden ließ. Seine Grenzen sind real. Was die Arbeit sicher machte, war nie, dass das Modell recht hatte; es war der Harness, der Rendering-Drift fing, und der Mensch, der Feel fing. Und doch konnte ich es im Alltag nicht zuverlässig von den führenden geschlossenen Modellen unterscheiden, die wir ebenfalls nutzen — Opus-Klasse-Systeme. Auf einigen Achsen war es offensichtlich besser: schneller und weit weniger geneigt, die Codebasis mit defensivem Design aufzufüllen.
Wie wir künftig bauen wollen
Wir sind fertig mit der agentic-Entwicklung nach großer Spezifikation — dem Stil, in dem man einem Modell eine große Spezifikation übergibt und akzeptiert, was auch immer ankommt. Der Fehlermodus ist nicht schlechter Code; es ist Code, den niemand versteht.
Was hier funktioniert hat und was wir behalten werden: kleine Schritte, jeder besprochen, bevor er beginnt, jeder vom Menschen verstanden, bevor er gebaut wird, jeder verifiziert auf dem Gerät, auf dem er leben wird. Die Aufgabe des Modells ist es, schnell, präzise und ehrlich über Unsicherheit zu sein. Die Aufgabe des Menschen ist Urteil, Geschmack und e2e-Verifikation — besonders für UI-first-Consumer-Software, in der die Spezifikation nicht beschreiben kann, wie sich „richtig“ anfühlt. Kimi K3 auf Fireworks stellt sich als gut geformt für genau diesen Loop heraus: schnell genug, um den Loop eng zu halten, schlau genug, um die Schritte klein und sauber zu halten.
Die Welle ist weg, die Pipeline ist eine Idee statt zweier, und der Prozess, der uns dorthin gebracht hat, bleibt.