De renderpijplijn van Lulucat Notes herbouwen met Kimi K3
Onze Core Graphics-tegelpijplijn werd in zes kleine stappen een Metal-puntspritepijplijn, elk geverifieerd op een echte iPad. De code werd via pair-programming met Kimi K3 op Fireworks geschreven.
Vorige week veroorzaakte het slepen van een lassoselectie over Lulucat Notes een zichtbare golf: sommige schermtegels toonden de selectie op de nieuwe positie, terwijl andere in hetzelfde frame nog de oude positie lieten zien. We vervingen de volledige renderpijplijn — een Core Graphics-bitmap plus CATiledLayer — door Metal, in zes kleine stappen die elk op een echte iPad werden geverifieerd voordat de volgende begon.
De code werd via pair-programming geschreven met Kimi K3, het open model van Moonshot dat op Fireworks draait. De mens stuurde, besliste en testte; het model schreef vrijwel elke regel.
De pijplijn
De oude pijplijn kende twee soorten tekenwerk die langzaam uit elkaar dreven: streken werden voor goedkope weergave in een bitmap gebakken en als vectoren opnieuw getekend wanneer tegels meer detail nodig hadden. De nieuwe pijplijn heeft precies één idee: alles wat op inkt lijkt is een puntsprite. Een penstreek, een markeerstiftveeg en een wisdep zijn dezelfde 32-byte vertex — positie, diameter, kleur — getekend door hetzelfde shaderpaar als door de GPU gerasteriseerde cirkels met één punt tussenruimte langs de booglengte van de streek.
![]()
Bij één-punt-tussenruimte wijkt een keten van cirkels ongeveer 0.075 punt af van een wiskundig perfecte capsule — een vijfde van een pixel bij onze canvasdichtheid. In ruil daarvoor vallen drie gereedschappen samen tot één codepad, en doet de GPU waar hij het beste in is.
Rond dat idee is de architectuur eenvoudig. Vastgelegde inkt leeft in één 4096²-textuur. De UIScrollView blijft bestaan, gedegradeerd tot een pure gebarenengine: zijn contentOffset en zoomScale voeden elk frame een viewport-uniform, dus pannen en zoomen schrijven niets. Elke frame bestaat uit vijf tekenoperaties:
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
Bewerkingen schrijven rechtstreeks naar de textuur. Een streek vastleggen voegt zijn stempels toe. Wissen, verwijderen, verplaatsen en ongedaan maken spelen het getroffen gebied opnieuw af achter een scissorrechthoek: wis het gebied, teken de streken die het snijden opnieuw, klaar. Gedeeltelijk wissen behoudt de eigenaarschapssemantiek uit het vorige bericht — een wisactie hoort bij de streek waaruit hij inkt verwijdert — door elke gewiste streek in een scratch-textuur te tekenen, zijn eigen wispaden af te trekken met destination-out-blending (
De selectie die dit allemaal in gang zette wordt nu ook als puntsprites getekend. Door haar te slepen wordt één uniforme offset bijgewerkt. Nul tekstuurschrijvingen, nul tegelinvalidaties — de golf is structureel verdwenen, niet afgezwakt.
De poortwachter: een pixeldiff
We hebben de Core Graphics-renderer niet verwijderd. We degradeerden hem tot een offline referentie-implementatie, en elke Metal-wijziging moet slagen voor een pixelvergelijking daarmee op echte streekdata die op het apparaat is vastgelegd. Het acceptatiecriterium is niet “identieke pixels” — twee correcte rasterizers mogen legitiem een paar grijswaarden langs antialias-randen verschillen. De poort is structureel: geen ontbrekende inkt, geen offset, geen kleurverschuiving en nergens een groot verschil ver van inkt.
![]()
Deze harness ving vier van de vijf bugs die we tijdens het bouwen van de offline renderer tegenkwamen, alle gediagnosticeerd door de uitvoer tot pixelniveau uit te snijden: een stride-fout in een Swift/Metal-struct (28 bytes tegenover 32, omdat Metal float4 op 16 uitlijnt — het scherm vulde zich met kleurblokken), [[point_size]] dat als varying onleesbaar was in de fragmentshader (elke stempel werd vierkant), twee render-encoders die samen op één commandbuffer bestonden (alles zwart), en een ontbrekend begindep dat de eerste millimeter van snelle streken onzichtbaar liet.
Zes stappen, geen enkele herschrijving
Het migratieplan bestond uit zes onafhankelijk uitleverbare stappen: offline renderer die de pixeldiff doorstaat; weergaveschil met nul visuele verandering; live-streek op de GPU; selectie op de GPU; mutaties die rechtstreeks naar de textuur schrijven; en bij hoge zoom vectoren opnieuw tekenen (ingepast in stap twee omdat “nul visuele verandering” dat eiste). Elke stap eindigde met een mens — geen simulator, geen schermafbeeldingsdiff — die op de iPad Pro op het bureau schreef, wiste, zoomde en sleepte.
Het apparaat ving drie bugs die elke geautomatiseerde controle miste. Boven 100% zoom werden streken twee keer getekend — zachte textuur eronder, scherpe sprites erop — wat eruitzag als een vage vervaging die de mens binnen seconden opmerkte. Het vastleggen van een streek flikkerde één frame lang omdat de oude overlay uitfademde zonder synchroon te lopen met de textuurupdate. En boven 170% zoom verdween elke notitie: de visibility-culling-rechthoek gebruikte contentOffset in zijn geschaalde coördinatenruimte, dus dreef hij weg van de streken naarmate je zoomde. Alle drie waren reparaties van één regel in één functie, en geen ervan had kunnen worden opgevangen door een test die we vooraf hadden kunnen schrijven, omdat we niet wisten dat we ernaar moesten zoeken. Daarom houden we bij een UI-first consumentenapp een mens in de loop.
Hoe het is om met Kimi K3 te werken
Snel, om te beginnen. De loop van “bespreken, schrijven, bouwen, installeren, kijken” liep in minuten, en een model dat snel antwoordt verandert hoeveel loops je je op een dag kunt veroorloven.
Ten tweede over-engineert het niet. Deze codebase draait op expliciete huisregels — geen backward-compatibility-steigerwerk voor de lancering, complexiteit pas wanneer een apparaat die noodzakelijk bewijst — en K3 volgt ze zonder eraan herinnerd te worden. Het een prompt geven voelt als samenwerken met een bekwame collega die de huisregels heeft gelezen en er echt in gelooft.
Ten derde, geef het gereedschappen en het gebruikt ze gretig. We sloten image-hulpprogramma’s aan — bekijken, uitsnijden tot een pixelgebied, schalen — en het model begon proactief zijn eigen rendereruitvoer uit te snijden om de vijf harness-bugs hierboven te diagnosticeren. Het gereedschap herinnerde het model eraan te kijken.
De andere helft: K3 schreef de meeste bugs in dit verhaal, inclusief de coördinatenruimtebug die notities liet verdwijnen. Zijn grenzen zijn echt. Wat het werk veilig maakte was nooit dat het model gelijk had; het was de harness die renderdrift ving en de mens die het gevoel beoordeelde. En toch kon ik, dag in dag uit, het niet betrouwbaar onderscheiden van de gesloten frontiermodellen die we ook gebruiken — Opus-klasse-systemen. Op sommige vlakken was het ronduit beter: sneller, en veel minder geneigd de codebase vol te stoppen met defensief ontwerp.
Hoe we vanaf nu willen bouwen
We zijn klaar met agentic development op basis van grote specificaties — de stijl waarbij je een model een grote specificatie overhandigt en accepteert wat eruit komt. De faalmodus is niet slechte code; het is code die niemand begrijpt.
Wat hier werkte, en wat we zullen behouden: kleine stappen: elke stap wordt besproken voordat die begint, door de mens begrepen voordat die wordt gebouwd, en geverifieerd op het apparaat waarop die zal draaien. De taak van het model is snel, precies en eerlijk over onzekerheid zijn. De taak van de mens is oordeel, smaak en e2e-verificatie — vooral voor op de UI gerichte consumentensoftware, waarin de specificatie niet kan beschrijven hoe “goed” aanvoelt. Kimi K3 op Fireworks blijkt precies geschikt voor deze loop: snel genoeg om de loop strak te houden, slim genoeg om de stappen klein en schoon te houden.
De golf is weg, de pijplijn is één idee in plaats van twee, en het proces dat ons daar bracht blijft.