Lulucat

Att bygga om Lulucat Notes renderingspipeline med Kimi K3

Gaoge ZhangGaoge Zhang

Vår Core Graphics-tile-pipeline blev en Metal-pipeline med point sprites i sex små steg, vart och ett verifierat på en riktig iPad. Koden parprogrammerades med Kimi K3 på Fireworks.

Uppdaterad

Förra veckan skapade en lassomarkering som drogs över Lulucat Notes en synlig våg: i samma bildruta visade vissa skärmtiles markeringen på den nya positionen medan andra fortfarande visade den gamla. Vi ersatte hela renderingspipelinen — Core Graphics-bitmapp plus CATiledLayer — med Metal, i sex små steg, vart och ett verifierat på en riktig iPad innan nästa började.

En sak till om författarskapet, eftersom det spelar roll för andra halvan av det här inlägget: koden parprogrammerades med Kimi K3, Moonshots öppna modell, som kördes på Fireworks. Människan drev arbetet, fattade besluten och testade; modellen skrev nästan varje rad.

Pipelinen

Den gamla pipelinen hade två sorters ritande som långsamt gled isär: streck bakades in i en bitmapp för billig visning och ritades om som vektorer när tiles behövde mer detalj. Den nya pipelinen har exakt en idé: allt bläckliknande är en point sprite. Ett pennstreck, ett överstrykningsdrag och ett suddgummidopp är samma 32-byte-vertex — position, diameter, färg — ritad av samma shaderpar som GPU-rasteriserade cirklar med en punkts mellanrum längs streckets båglängd.

Tre paneler av samma böjda streck: indataberöringspunkter, cirkulära stämplar placerade längs kurvan efter båglängd, och det sammansatta solida strecket.

Vid en punkts mellanrum avviker en kedja av cirklar från en matematiskt perfekt kapsel med ungefär 0.075 punkter — en femtedels pixel vid vår canvasdensitet. I utbyte kollapsar tre verktyg till en kodväg, och GPU:n gör det den är bäst på.

Kring den idén är arkitekturen enkel. Kommittat bläck lever i en enda 4096²-textur. UIScrollView överlever, nedgraderad till en ren gestmotor: dess contentOffset och zoomScale matar en viewport-uniform varje bildruta, så panorering och zoomning skriver ingenting. Varje bildruta består av fem ritningar:

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

Redigeringar skriver direkt till texturen. Att committa ett streck lägger till dess stämplar. Suddning, radering, flyttning och ångring spelar upp den påverkade regionen med en scissor-rektangel: rensa regionen, rita om de streck som skär den, klart. Partiell radering behåller ägandesemantiken från föregående inlägg — en radering hör till strecket vars bläck tas bort — genom att rita varje raderat streck till en scratch-textur, subtrahera dess egna raderingsbanor med destination-out-blandning () och kompositera tillbaka resultatet. Scratch-isoleringen hindrar ett suddgummi från att bita igenom papper eller grannstreck.

Markeringen som startade allt det här ritas nu också som point sprites. Att dra den uppdaterar en enda uniform offset. Noll texturskrivningar, inga tile-invalideringar — vågen är strukturellt borta, inte bara mildrad.

Portvakten: en pixel-diff

Vi raderade inte Core Graphics-renderaren. Vi degraderade den till en offline-referensimplementation, och varje Metal-ändring måste klara en pixeljämförelse mot den på riktig streckdata som fångats på enheten. Acceptanskriteriet är inte “identiska pixlar” — två korrekta rasteriserare kan legitimt skilja sig åt med några grånivåer längs kantutjämnade kanter. Grinden är strukturell: inget saknat bläck, ingen offset, ingen färgdrift och ingen stor skillnad någonstans bort från bläcket.

Tre utsnitt av samma handskrivna anteckningar: renderade av Core Graphics, renderade av Metal point sprites, och deras pixeldiff förstorad sex gånger, som visar endast svaga konturer längs streckkanter.

Den här testbädden fångade fyra av de fem fel vi stötte på när vi byggde offline-renderaren; alla diagnostiserades genom att beskära utdata till pixelnivå: ett Swift/Metal-struct-stride-fel (28 bytes mot 32, eftersom Metal alignerar float4 till 16 — skärmen fylldes med färgblock), att [[point_size]] var oläslig som en varying i fragmentshadern (varje stämpel blev fyrkantig), två render-encoders som samexisterade i en command buffer (allt svart), och ett saknat startdopp som lämnade den första millimetern av snabba streck osynlig.

Sex steg, inte en hel omskrivning

Migreringsplanen bestod av sex oberoende levererbara steg: offline-renderare som klarar pixel-diffen; visningsskal med noll visuell förändring; levande streck på GPU; markering på GPU; mutationer som skriver direkt till texturen; vektor-omritning vid hög zoom (inkluderad i steg två, eftersom “noll visuell förändring” krävde det). Varje steg avslutades med att en människa — inte en simulator, inte en skärmbildsdiff — skrev, suddade, zoomade och drog på iPad Pro:n på skrivbordet.

Enheten fångade tre fel som alla automatiserade kontroller missade. Vid zoom över 100 % ritades streck dubbelt — mjuk textur undertill, skarpa sprites ovanpå — vilket lästes som en svag oskärpa som människan lade märke till på sekunder. Att committa ett streck blinkade under en bildruta eftersom det gamla överlägget korsfade ur synk med texturens uppdatering. Och vid zoom över 170 % försvann varje anteckning: rektangeln för synlighets-culling använde contentOffset i sitt skalade koordinatrum, så den drev iväg från strecken i takt med att du zoomade. Alla tre kunde fixas med en rad i en enda funktion, och inget av dem fanns i något test vi kunde ha skrivit i förväg, eftersom vi inte visste att vi skulle leta efter dem. För en UI-först konsumentapp är det därför människan stannar i loopen: man kan inte räkna upp hur “känslan” bryts.

Hur det är att arbeta med Kimi K3

Snabbt, först och främst. Loopen “diskutera, skriv, bygg, installera, titta” gick på minuter, och en modell som svarar snabbt ändrar hur många loopar man har råd med under en dag.

För det andra: den överkonstruerar inte. Den här kodbasen styrs av explicita husregler — ingen bakåtkompatibilitetsinfrastruktur före lansering, komplexitet först när en enhet visar att den behövs — och K3 följer dem utan att bli påmind. Den lade inte till spatiala index “för senare”, slog inte in varje anrop i defensiva kontroller, abstraherade inte spekulativt. Att prompta den känns som att arbeta med en kompetent kollega som har läst husreglerna och faktiskt tror på dem.

För det tredje: ge den verktyg och den använder dem ivrigt. Vi kopplade in bildverktyg — visa, beskär till en pixelregion, ändra storlek — och modellen började proaktivt beskära sin egen renderarutdata för att diagnostisera de fem testbäddsfelen ovan. Att ha verktyget påminde den om att titta.

Andra halvan: K3 skrev de flesta av felen i den här berättelsen, inklusive det med koordinatrummet som fick anteckningar att försvinna. Dess gränser är verkliga. Det som gjorde arbetet säkert var aldrig att modellen hade rätt; det var testbädden som fångade renderingsdrift och människan som fångade känslan. Och ändå, dag för dag, kunde jag inte pålitligt skilja den från de ledande slutna modellerna vi också använder — Opus-klass-system. På vissa axlar var den uppenbart bättre: snabbare och mycket mindre benägen att fylla ut kodbasen med defensiv design.

Hur vi vill bygga hädanefter

Vi är klara med agentisk utveckling utifrån stora specifikationer — stilen där man ger en modell en stor specifikation och accepterar det som landar. Det som går fel är inte dålig kod; det är kod som ingen förstår.

Det som fungerade här, och det vi kommer att behålla: små steg, vart och ett diskuterat innan det börjar, vart och ett förstått av människan innan det byggs, vart och ett verifierat på den enhet där det ska leva. Modellens jobb är att vara snabb, precis och ärlig om osäkerhet. Människans jobb är omdöme, smak och e2e-verifiering — särskilt för UI-först konsumentmjukvara, där specifikationen inte kan beskriva hur “rätt” känns. Kimi K3 på Fireworks visar sig vara väl lämpad för exakt den här loopen: tillräckligt snabb för att hålla loopen tajt, tillräckligt smart för att hålla stegen små och rena.

Vågen är borta, pipelinen är en idé i stället för två, och processen som tog oss dit blir kvar.