Lulucat

Genopbygning af Lulucat Notes' gengivelsespipeline med Kimi K3

Gaoge ZhangGaoge Zhang

Vores Core Graphics-tile-pipeline blev til en Metal-punktsprite-pipeline i seks små trin, hvert verificeret på en rigtig iPad. Koden blev parprogrammeret med Kimi K3 på Fireworks.

Opdateret

Sidste uge skabte det en synlig bølge at trække en lassomarkering hen over Lulucat Notes: nogle skærmtiles viste markeringen på dens nye plads, mens andre stadig viste den gamle i det samme frame. Vi udskiftede hele gengivelsespipelinen — Core Graphics-bitmap plus CATiledLayer — med Metal i seks små trin, hvert verificeret på en rigtig iPad, før det næste begyndte.

Koden blev parprogrammeret med Kimi K3, Moonshots åbne model, der kører på Fireworks. Mennesket styrede, traf beslutninger og testede; modellen skrev næsten hver eneste linje.

Pipelinen

Den gamle pipeline havde to slags tegning, der langsomt gled fra hinanden: streger blev bagt ind i en bitmap til billig visning og tegnet igen som vektorer, når tiles skulle bruge mere detalje. Den nye pipeline har præcis én idé: alt blæklignende er en punktsprite. Et pennestrøg, et overstregningsstrøg og et viskelædertryk er den samme 32-byte vertex — position, diameter, farve — tegnet af det samme shaderpar som GPU-rasteriserede cirkler med en afstand på ét punkt langs stregerens buelængde.

Tre paneler af den samme krumme streg: input-berøringspunkter, cirkulære stempler placeret langs kurven efter buelængde, og den sammensatte, fyldte streg.

Ved en afstand på ét punkt afviger en kæde af cirkler fra en matematisk perfekt kapsel med ca. 0.075 punkter — en femtedel af en pixel ved vores lærredstæthed. Til gengæld samles tre værktøjer i én kodevej, og GPU’en gør det, den er bedst til.

Omkring den idé er arkitekturen enkel. Det committede blæk lever i én enkelt 4096²-tekstur. UIScrollView’en overlever, degraderet til en ren gestusmotor: dens contentOffset og zoomScale fodrer en viewport-uniform i hvert frame, så panorering og zoom skriver ingenting. Hvert frame er 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

Redigeringer skriver direkte i teksturen. At committe et strøg tilføjer dets stempler. Udviskning, sletning, flytning og fortryd afspiller det berørte område bag et scissor-rektangel: ryd området, tegn de streger, der skærer det, igen, og så er det gjort. Delvis udviskning bevarer ejerskabssemantikken fra det forrige indlæg — en udviskning hører til den streg, den fjerner blæk fra — ved at tegne hver udvisket streg ind i en scratch-tekstur, trække dens egne udviskningsbaner fra med destination-out-blanding () og komponere resultatet tilbage. Scratch-isoleringen forhindrer et viskelæder i at skære igennem papir eller tilstødende streger.

Den markering, der startede det hele, tegnes nu også som punktsprites. At trække i den opdaterer én uniform-forskydning. Nul teksturskrivninger, nul tile-invalideringer — bølgen er strukturelt væk, ikke afbødet.

Vogteren: et pixeldiff

Vi slettede ikke Core Graphics-rendereren. Vi degraderede den til en offline-referenceimplementering, og hver Metal-ændring skal bestå en pixelsammenligning mod den på rigtige stregdata optaget på enheden. Acceptkriteriet er ikke »identiske pixels« — to korrekte rasterisatorer kan legitimt være uenige om nogle få gråniveauer langs anti-aliaserede kanter. Porten er strukturel: intet manglende blæk, ingen forskydning, ingen farveglidning og ingen stor forskel noget sted væk fra blæk.

Tre udsnit af de samme håndskrevne noter: gengivet med Core Graphics, gengivet med Metal-punktsprites, og deres pixelforskel forstørret seks gange, der kun viser svage omrids langs stregkanter.

Dette harness fangede fire af de fem fejl, vi stødte på, mens vi byggede den offline-renderer, alle diagnosticeret ved at beskære outputtet til pixelniveau: en Swift/Metal struct-stride-uoverensstemmelse (28 bytes mod 32, fordi Metal aligner float4 til 16 — skærmen fyldtes med farveblokke), [[point_size]] var ulæselig som en varying i fragment-shaderen (hvert stempel kom ud firkantet), to render-encodere sameksisterede på én command buffer (alt blev sort), og et manglende startstempel, der efterlod den første millimeter af hurtige streger usynlig.

Seks trin, ikke én omskrivning

Migrationsplanen var seks uafhængigt leverérbare trin: offline-renderer, der bestod pixel-diffen; visningsskal med nul visuel ændring; live-streg på GPU; markering på GPU; mutationer, der skrev direkte i teksturen; vektor-gentegning ved høj zoom (foldet ind i trin to, fordi »nul visuel ændring« krævede det). Hvert trin sluttede med et menneske, der skrev, viskede ud, zoomede og trak på den iPad Pro, der stod på skrivebordet — ikke en simulator og ikke et skærmbillede-diff.

Enheden fangede tre fejl, som ethvert automatiseret tjek overså. Over 100% zoom blev streger tegnet to gange — blød tekstur nedenunder, skarpe sprites ovenpå — hvilket læstes som et svagt slør, som mennesket lagde mærke til på sekunder. At committe et strøg flimrede i ét frame, fordi det gamle overlay fadede ud af synkronisering med teksturopdateringen. Og over 170% zoom forsvandt hver note: visibility-culling-rektanglet brugte contentOffset i sit skalerede koordinatrum, så det drev væk fra stregerne, når du zoomede. Alle tre var rettelser på én linje i én funktion, og ingen af dem fandtes i nogen test, vi kunne have skrevet på forhånd, fordi vi ikke vidste, at vi skulle lede efter dem. Det er derfor, et menneske skal blive i løkken i en UI-først forbrugsapp.

Hvordan det er at arbejde med Kimi K3

Hurtig, først og fremmest. Loopet »diskutér, skriv, byg, installér, kig« kørte på minutter, og en model, der svarer hurtigt, ændrer, hvor mange loops man har råd til på en dag.

For det andet over-engineerer den ikke. Denne kodebase kører efter eksplicitte husregler — intet bagudkompatibilitets-stillads før lancering, kompleksitet kun når en enhed beviser det nødvendigt — og K3 følger dem uden at blive mindet om dem. Den tilføjede ikke rumlige indekser »til senere«, indpakkede ikke hvert kald i defensive tjek og abstraherede ikke spekulativt. At prompte den føles som at arbejde med en kompetent kollega, der har læst husreglerne og rent faktisk tror på dem.

For det tredje: giv den værktøjer, og den bruger dem ivrigt. Vi koblede billedværktøjer på — vis, beskær til et pixelområde, tilpas størrelse — og modellen begyndte proaktivt at beskære sit eget renderer-output for at diagnosticere de fem harness-fejl ovenfor. At have værktøjet mindede den om at kigge.

Den anden halvdel: K3 skrev de fleste af fejlene i denne historie, herunder den med koordinatrummet, der fik noter til at forsvinde. Dens begrænsninger er virkelige. Det, der gjorde arbejdet sikkert, var aldrig, at modellen havde ret; det var harnessen, der fangede gengivelsesdrift, og mennesket, der fangede følelsen. Og dog kunne jeg dag til dag ikke pålideligt kende den fra de lukkede frontmodeller, vi også bruger — Opus-klasse-systemer. På nogle akser var den klart bedre: hurtigere og langt mindre tilbøjelig til at tilstoppe kodebasen med defensivt design.

Sådan vil vi bygge fremover

Vi er færdige med agent-baseret udvikling ud fra store specifikationer — stilen, hvor man overdrager en model en stor specifikation og accepterer det, der lander. Fejlmåden er ikke dårlig kode; det er kode, ingen forstår.

Hvad der virkede her, og hvad vi vil beholde: små trin, hvert diskuteret, før det begynder, hvert forstået af mennesket, før det bygges, hvert verificeret på den enhed, det skal leve på. Modellens opgave er at være hurtig, præcis og ærlig om usikkerhed. Menneskets opgave er vurdering, smag og e2e-verifikation — især for UI-først forbrugssoftware, hvor specifikationen ikke kan beskrive, hvordan »rigtigt« føles. Kimi K3 på Fireworks viser sig at være godt formet til netop dette loop: hurtig nok til at holde loopet stramt, klog nok til at holde trinene små og rene.

Bølgen er væk, pipelinen er én idé i stedet for to, og den proces, der bragte os dertil, forbliver.