Å bygge om gjengivelsespipelinen i Lulucat Notes med Kimi K3
Core Graphics-tile-pipelinen vår ble til en Metal point-sprite-pipeline i seks små trinn, hvert verifisert på en ekte iPad. Koden ble skrevet i parprogrammering med Kimi K3 på Fireworks.
Forrige uke skapte det en synlig bølge å dra et lassoutvalg over Lulucat Notes: noen skjermfliser viste utvalget på den nye plasseringen, mens andre fortsatt viste den gamle, i samme bilde. Vi erstattet hele gjengivelsespipelinen — Core Graphics-bitmap pluss CATiledLayer — med Metal i seks små trinn, og hvert trinn ble verifisert på en ekte iPad før det neste startet.
Koden ble skrevet i parprogrammering med Kimi K3, Moonshots åpne modell som kjører på Fireworks. Mennesket styrte, bestemte og testet; modellen skrev nesten hver eneste linje.
Pipelinen
Den gamle pipelinen hadde to typer tegning som sakte gled fra hverandre: strøk ble bakt inn i en bitmap for billig visning, og tegnet på nytt som vektorer hver gang flisene trengte mer detalj. Den nye pipelinen har nøyaktig én idé: alt som ligner på blekk er en point sprite. Et pennestrøk, et highlightersveip og et viskelærstikk er den samme 32-byte-vertexen — posisjon, diameter, farge — tegnet av det samme shader-paret som GPU-rasteriserte sirkler med ett punkts avstand langs strøkets buelengde.
![]()
Med ett punkts avstand avviker en kjede av sirkler fra en matematisk perfekt kapsel med rundt 0.075 punkter — en femtedel av en piksel ved vår lerretstetthet. Til gjengjeld blir tre verktøy til én kodebane, og GPU-en gjør det den er best til.
Rundt den idéen er arkitekturen enkel. Kommittert blekk lever i én enkelt 4096²-tekstur. UIScrollView-en overlever, degradert til en ren gestmotor: dens contentOffset og zoomScale mater en viewport-uniform for hvert bilde, så panorering og zooming skriver ingenting. Hvert bilde består av fem tegninger:
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
Endringer skriver direkte til teksturen. Å committe et strøk legger til stemplene dets. Visking, sletting, flytting og angre spiller av det berørte området på nytt bak et sakserektangel: tøm området, tegn strøkene som krysser det på nytt, ferdig. Delvis visking beholder eiersemantikken fra forrige innlegg — en visking tilhører strøket den fjerner blekk fra — ved å tegne hvert viskede strøk inn i en scratch-tekstur, trekke fra sine egne viskestier med destination-out-blending (
Utvalget som startet alt dette tegnes nå også som point sprites. Å dra det oppdaterer én uniform-forskyvning. Null teksturskrivinger, null tile-invalideringer — bølgen er strukturelt borte, ikke dempet.
Portvokteren: en pikseldiff
Vi slettet ikke Core Graphics-rendereren. Vi degraderte den til en frakoblet referanseimplementasjon, og hver Metal-endring må bestå en pikselsammenlikning mot den på ekte strøkdata fanget på enheten. Akseptkriteriet er ikke «identiske piksler» — to korrekte rasterisatorer kan legitimt avvike med noen få grånivåer langs kantutjevnede kanter. Porten er strukturell: ingen manglende blekk, ingen forskyvning, ingen fargedrift og ingen stor forskjell noe sted borte fra blekk.
![]()
Dette testoppsettet fanget fire av de fem feilene vi traff mens vi bygde den frakoblede rendereren, alle diagnostisert ved å beskjære utskriften til pikselnivå: en Swift/Metal-struct-stride-feil (28 byte mot 32, fordi Metal justerer float4 til 16 — skjermen fyltes med fargeblokker), [[point_size]] som ikke var lesbar som en varying i fragment-shaderen (hvert stempel ble firkantet), to render-encodere som sameksisterte på én kommandobuffer (alt ble svart), og et manglende startstikk som lot den første millimeteren av raske strøk være usynlig.
Seks trinn, ikke én ombygging
Migrasjonsplanen var seks uavhengig leverbare trinn: frakoblet renderer som består pikseldiffen; visningsskall med null visuell endring; live-strøk på GPU; utvalg på GPU; mutasjoner som skriver direkte til teksturen; vektor-gjentegning ved høy zoom (lagt inn i trinn to fordi «null visuell endring» krevde det). Hvert trinn endte med et menneske — ikke en simulator, ikke en skjermbilddiff — som skrev, visket, zoomet og dro på iPad Pro-en på skrivebordet.
Enheten fanget tre feil som hver automatiserte sjekk overså. Over 100% zoom ble strøk tegnet to ganger — myk tekstur under, skarpe sprites på toppen — noe som opplevdes som et svakt slør som mennesket la merke til på sekunder. Å committe et strøk flimret i ett bilde fordi det gamle overlegget kryss-fadet ut av takt med teksturoppdateringen. Og over 170% zoom forsvant hvert notat: rektangelet for synlighetskulling brukte contentOffset i sitt skalerte koordinatrom, så det drev bort fra strøkene etter hvert som du zoomet. Alle tre var endringer på én linje i én funksjon, og ingen av dem kunne ha blitt fanget av en test vi kunne ha skrevet på forhånd, for vi visste ikke å lete etter dem. Det er derfor vi beholder et menneske i løkken for en UI-først forbrukerapp.
Hvordan det er å jobbe med Kimi K3
Raskt, først og fremst. Løkken «diskuter, skriv, bygg, installer, se på» gikk på minutter, og en modell som svarer raskt endrer hvor mange løkker du har råd til i løpet av en dag.
For det andre over-engineerer den ikke. Denne kodebasen kjører på eksplisitte husregler — ingen bakoverkompatibilitets-stillas før lansering, kompleksitet bare når en enhet beviser at det er nødvendig — og K3 følger dem uten å bli minnet på det. Den la ikke til romlige indekser «til senere», pakket ikke inn hvert kall i defensive sjekker og spekulerte ikke på abstraksjon. Det føles som å skrive prompts til den og jobbe med en kompetent kollega som har lest husreglene og faktisk tror på dem.
For det tredje, gi den verktøy og den bruker dem ivrig. Vi koblet opp bildeverktøy — vis, beskjær til et pikselområde, endre størrelse — og modellen begynte proaktivt å beskjære sin egen renderer-utskrift for å diagnostisere de fem testoppsett-feilene over. Å ha verktøyet minnet den på å se.
Den andre halvdelen: K3 skrev de fleste feilene i denne historien, inkludert den med koordinatrommet som fikk notater til å forsvinne. Grensene dens er ekte. Det som gjorde arbeidet trygt var aldri at modellen hadde rett; det var testoppsettet som fanget renderingsdrift og mennesket som vurderte brukeropplevelsen. Og likevel, dag for dag, kunne jeg ikke pålitelig skille den fra de fremste lukkede modellene vi også bruker — systemer på Opus-nivå. På noen områder var den tydelig bedre: raskere, og langt mindre tilbøyelig til å fylle kodebasen med defensivt design.
Hvordan vi vil bygge heretter
Vi er ferdige med agentisk utvikling basert på store spesifikasjoner — stilen der du overrekker en modell en stor spesifikasjon og aksepterer det som kommer ut. Feilmodusen er ikke dårlig kode; det er kode ingen forstår.
Det som fungerte her, og det vi vil beholde: små trinn, hvert diskutert før det starter, hvert forstått av mennesket før det bygges, hvert verifisert på enheten det skal kjøre på. Modellens jobb er å være rask, presis og ærlig om usikkerhet. Menneskets jobb er dømmekraft, smak og e2e-verifisering — spesielt for UI-først forbrukerprogramvare, der spesifikasjonen ikke kan beskrive hvordan «riktig» føles. Kimi K3 på Fireworks viser seg å være godt egnet for akkurat denne løkken: rask nok til å holde løkken stram, smart nok til å holde trinnene små og rene.
Bølgen er borte, pipelinen er én idé i stedet for to, og prosessen som fikk oss dit blir værende.