Ricostruire la pipeline di rendering di Lulucat Notes con Kimi K3
La nostra pipeline di tile di Core Graphics è diventata una pipeline di point sprite su Metal in sei piccoli passaggi, ciascuno verificato su un iPad reale. Il codice è stato scritto in pair programming con Kimi K3 su Fireworks.
La settimana scorsa, trascinando una selezione a lazo su Lulucat Notes, compariva un’onda visibile: alcuni tile dello schermo mostravano la selezione nella nuova posizione, mentre altri mostravano ancora quella vecchia, nello stesso frame. Abbiamo sostituito l’intera pipeline di rendering — bitmap di Core Graphics più CATiledLayer — con Metal in sei piccoli passaggi, verificando ciascuno su un iPad reale prima di iniziare il successivo.
Il codice è stato scritto in pair programming con Kimi K3, il modello open di Moonshot in esecuzione su Fireworks. L’essere umano guidava, decideva e testava; il modello ha scritto quasi ogni riga.
La pipeline
La vecchia pipeline aveva due tipi di disegno che lentamente si separavano: i tratti venivano incorporati in una bitmap per una visualizzazione economica e ridisegnati come vettori ogni volta che i tile avevano bisogno di maggior dettaglio. La nuova pipeline ha un’unica idea: tutto ciò che assomiglia all’inchiostro è un point sprite. Un tratto di penna, una passata di evidenziatore e un tocco di gomma sono lo stesso vertex di 32 byte — posizione, diametro, colore — disegnato dalla stessa coppia di shader come cerchi rasterizzati dalla GPU, distanziati di un point lungo la lunghezza d’arco del tratto.
![]()
Con una spaziatura di un point, una catena di cerchi devia da una capsula matematicamente perfetta di circa 0.075 point — un quinto di pixel alla nostra densità del canvas. In cambio, tre strumenti confluiscono in un unico percorso di codice e la GPU fa ciò in cui è migliore.
Attorno a quell’idea, l’architettura è semplice. L’inchiostro consolidato vive in una singola texture 4096². La UIScrollView sopravvive, relegata a puro motore di gesture: il suo contentOffset e la sua zoomScale alimentano un uniform del viewport a ogni frame, quindi panning e zooming non scrivono nulla. Ogni frame è composto da cinque operazioni di disegno:
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
Le modifiche scrivono direttamente nella texture. Confermare un tratto aggiunge i suoi timbri. Cancellare, eliminare, spostare e annullare riproducono la regione interessata dietro un rettangolo di scissor: si svuota la regione, si ridisegnano i tratti che la intersecano, fatto. La cancellazione parziale conserva la semantica di ownership del post precedente — una cancellazione appartiene al tratto da cui rimuove l’inchiostro — disegnando ogni tratto cancellato in una texture scratch, sottraendo i propri percorsi di cancellazione con destination-out blending (
La selezione che ha dato inizio a tutto questo ora è disegnata anch’essa come point sprite. Trascinarla aggiorna un solo offset uniform. Zero scritture sulla texture, zero invalidazioni di tile: l’onda è scomparsa strutturalmente, non è stata semplicemente mitigata.
Il guardiano: un pixel diff
Non abbiamo eliminato il renderer di Core Graphics. Lo abbiamo relegato a implementazione di riferimento offline, e ogni modifica a Metal deve superare un confronto a livello di pixel con esso, usando dati reali di tratti catturati sul dispositivo. Il criterio di accettazione non è “pixel identici”: due rasterizer corretti possono legittimamente differire di qualche livello di grigio lungo i bordi anti-alias. Il gate è strutturale: nessun inchiostro mancante, nessun offset, nessuna deriva di colore e nessuna grande differenza in alcun punto lontano dall’inchiostro.
![]()
Questo harness ha catturato quattro dei cinque bug che abbiamo incontrato costruendo il renderer offline, tutti diagnosticati ritagliando l’output a livello di pixel: un mismatch dello stride di una struct Swift/Metal (28 byte contro 32, perché Metal allinea float4 a 16 — lo schermo si è riempito di blocchi di colore), [[point_size]] illeggibile come varying nel fragment shader (ogni timbro risultava quadrato), due render encoder coesistenti su un solo command buffer (tutto nero) e un timbro iniziale mancante che lasciava invisibile il primo millimetro dei tratti rapidi.
Sei passaggi, non un’unica riscrittura
Il piano di migrazione era costituito da sei passaggi rilasciabili indipendentemente: renderer offline che superasse il pixel diff; shell di visualizzazione senza alcun cambiamento visivo; tratto dal vivo sulla GPU; selezione sulla GPU; mutazioni che scrivessero direttamente nella texture; ridisegno vettoriale ad alto zoom (integrato nel secondo passaggio, perché “zero cambiamento visivo” lo esigeva). Ogni passaggio si concludeva con una persona — non un simulatore, non un diff di screenshot — che scriveva, cancellava, zoomava e trascinava sull’iPad Pro sulla scrivania.
Il dispositivo ha catturato tre bug che ogni controllo automatizzato aveva mancato. Sopra il 100% di zoom, i tratti venivano disegnati due volte — texture morbida sotto, sprite nitidi sopra — e il risultato appariva come una sfocatura tenue che una persona notava in pochi secondi. Confermare un tratto faceva lampeggiare un frame perché il vecchio overlay eseguiva il cross-fade in uscita fuori sincrono con l’aggiornamento della texture. E sopra il 170% di zoom, ogni nota scompariva: il rettangolo di visibility-culling usava contentOffset nel proprio spazio di coordinate scalato, quindi si allontanava dai tratti mentre si aumentava lo zoom. Tutti e tre erano correzioni di una riga in una funzione ciascuna, e nessuno di essi sarebbe stato coperto da un test che avremmo potuto scrivere in anticipo, perché non sapevamo cosa cercare. Per un’app consumer UI-first, è per questo che l’essere umano resta nel ciclo.
Com’è lavorare con Kimi K3
Prima di tutto, è veloce. Il ciclo “discutere, scrivere, compilare, installare, guardare” durava pochi minuti, e un modello che risponde rapidamente cambia quanti cicli ci si può permettere in una giornata.
Secondo, non sovraprogetta. Questa codebase si basa su regole interne esplicite — niente impalcature di backward-compatibility prima del lancio, complessità solo quando un dispositivo ne dimostra la necessità — e K3 le segue senza che glielo si ricordi. Non ha aggiunto indici spaziali “per dopo”, non ha avvolto ogni chiamata in controlli difensivi e non ha astratto in modo speculativo. Dargli un prompt è come lavorare con un collega competente che ha letto le regole interne e ci crede davvero.
Terzo, dagli degli strumenti e li usa con entusiasmo. Abbiamo collegato utility per le immagini — visualizzare, ritagliare una regione di pixel, ridimensionare — e il modello ha iniziato a ritagliare proattivamente il proprio output del renderer per diagnosticare i cinque bug dell’harness descritti sopra. La disponibilità dello strumento gli ricordava di guardare.
L’altra metà: K3 ha scritto la maggior parte dei bug in questa storia, compreso quello dello spazio di coordinate che faceva scomparire le note. I suoi limiti sono reali. A rendere sicuro il lavoro non è mai stato il fatto che il modello avesse ragione, ma l’harness che catturava la deriva del rendering e l’essere umano che ne coglieva la sensazione. Eppure, giorno dopo giorno, non riuscivo a distinguerlo in modo affidabile dai modelli chiusi di frontiera che usiamo anch’essi — sistemi di classe Opus. Sotto alcuni aspetti era chiaramente migliore: più veloce e molto meno incline a riempire la codebase di design difensivo.
Come vogliamo sviluppare da ora in poi
Abbiamo chiuso con lo sviluppo agentico basato su grandi specifiche — lo stile in cui si consegna a un modello una specifica ampia e si accetta qualsiasi cosa ne esca. La modalità di fallimento non è codice sbagliato; è codice che nessuno capisce.
Ciò che ha funzionato qui, e che terremo: piccoli passaggi, ciascuno discusso prima di iniziare, ciascuno compreso dalla persona prima di essere costruito, ciascuno verificato sul dispositivo su cui dovrà vivere. Il compito del modello è essere rapido, preciso e onesto sull’incertezza. Il compito della persona è esercitare giudizio, gusto e verifica e2e — specialmente per software consumer UI-first, dove la specifica non può descrivere la sensazione di ciò che è “giusto”. Kimi K3 su Fireworks si è rivelato particolarmente adatto a questo ciclo: abbastanza veloce da tenere il ciclo stretto, abbastanza intelligente da tenere i passaggi piccoli e puliti.
L’onda è scomparsa, la pipeline è un’unica idea invece di due, e il processo che ci ha portato fin qui resterà.