Lulucat

Reconstruirea pipeline-ului de randare Lulucat Notes cu Kimi K3

Gaoge ZhangGaoge Zhang

Pipeline-ul nostru de tile-uri Core Graphics a devenit un pipeline de point sprites în Metal în șase pași mici, fiecare verificat pe un iPad real. Codul a fost scris prin pair-programming cu Kimi K3 pe Fireworks.

Actualizat

Săptămâna trecută, tragerea unei selecții lasso peste Lulucat Notes producea o undă vizibilă: în același cadru, unele tile-uri de pe ecran afișau selecția în noua poziție, iar altele o afișau încă în cea veche. Am înlocuit întregul pipeline de randare — bitmap Core Graphics plus CATiledLayer — cu Metal, în șase pași mici, fiecare verificat pe un iPad real înainte de a începe următorul.

Încă un lucru despre autorul codului, important pentru a doua jumătate a acestei postări: codul a fost scris prin pair-programming cu Kimi K3, modelul deschis al Moonshot, rulând pe Fireworks. Omul a condus, a decis și a testat; modelul a scris aproape fiecare linie.

Pipeline-ul

Vechiul pipeline avea două moduri de desenare care s-au îndepărtat lent unul de celălalt: tușele erau încorporate într-un bitmap pentru afișare ieftină și redesenate ca vectori ori de câte ori tile-urile aveau nevoie de mai multe detalii. Noul pipeline are o singură idee: tot ce seamănă cu cerneala este un point sprite. O tușă de pix, o trecere de evidențiator și o atingere a radierei sunt același vertex de 32 de bytes — poziție, diametru, culoare — desenat de aceeași pereche de shadere ca niște cercuri rasterizate pe GPU, dispuse la intervale de un punct de-a lungul lungimii arcului tușei.

Trei panouri ale aceleiași tușe curbe: puncte de atingere de intrare, amprente circulare distanțate de-a lungul curbei după lungimea arcului și tușa solidă compusă.

La o distanță de un punct, un lanț de cercuri deviază de la o capsulă matematic perfectă cu aproximativ 0.075 puncte — o cincime de pixel la densitatea pânzei noastre. În schimb, trei instrumente se reduc la o singură cale de cod, iar GPU-ul face ceea ce știe mai bine.

În jurul acestei idei, arhitectura este simplă. Cerneala confirmată trăiește într-o singură textură de 4096². UIScrollView supraviețuiește, retrogradat la rolul unui motor pur de gesturi: contentOffset și zoomScale ale sale alimentează un uniform de viewport în fiecare cadru, așa că panoramarea și zoom-ul nu scriu nimic. Fiecare cadru are cinci desenări:

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

Editările scriu direct în textură. Confirmarea unei tușe îi adaugă amprentele. Ștergerea, eliminarea, mutarea și anularea redau regiunea afectată folosind un scissor rectangle: golește regiunea, redesenează tușele care o intersectează, gata. Ștergerea parțială păstrează semantica de proprietate din postarea anterioară — o ștergere aparține tușei din care elimină cerneală — desenând fiecare tușă ștearsă într-o textură scratch, scăzând propriile trasee de ștergere prin blending destination-out () și compunând rezultatul înapoi. Izolarea scratch împiedică radiera să muște prin hârtie sau prin tușele vecine.

Selecția care a pornit totul este acum desenată și ea ca point sprites. Tragerea ei actualizează un singur offset uniform. Zero scrieri în textură, zero invalidări de tile-uri — unda a dispărut structural, nu a fost doar atenuată.

Paznicul: o diferență la nivel de pixel

Nu am șters rendererul Core Graphics. L-am retrogradat la o implementare de referință offline, iar fiecare modificare Metal trebuie să treacă o comparație la nivel de pixel cu acesta, pe date reale de tușe capturate pe dispozitiv. Criteriul de acceptare nu este „pixeli identici” — două rasterizatoare corecte pot diferi în mod legitim cu câteva niveluri de gri de-a lungul marginilor anti-aliased. Poarta este structurală: nicio cerneală lipsă, niciun offset, nicio deviere de culoare și nicio diferență mare oriunde departe de cerneală.

Trei decupaje din aceleași notițe scrise de mână: randate de Core Graphics, randate ca point sprites Metal și diferența lor de pixeli amplificată de șase ori, arătând doar contururi slabe de-a lungul marginilor tușelor.

Acest harness a prins patru din cele cinci bug-uri pe care le-am întâlnit în timp ce construiam rendererul offline, toate diagnosticate prin decuparea ieșirii la nivel de pixel: o nepotrivire de stride a structurii Swift/Metal (28 bytes versus 32, deoarece Metal aliniază float4 la 16 — ecranul s-a umplut de blocuri de culoare), [[point_size]] nu putea fi citit ca varying în fragment shader (fiecare amprentă ieșea pătrată), două render encoders coexistau pe un singur command buffer (totul era negru) și o amprentă de start lipsă lăsa primul milimetru al tușelor rapide invizibil.

Șase pași, nu o singură rescriere

Planul de migrare a constat în șase pași livrabili independent: rendererul offline trecând diferența de pixeli; shell-ul de afișare cu zero schimbări vizuale; tușa live pe GPU; selecția pe GPU; mutațiile scriind direct în textură; redesenarea vectorială la zoom mare (inclusă în pasul doi, pentru că „zero schimbări vizuale” o cerea). Fiecare pas se încheia cu un om — nu un simulator, nu o diferență de screenshot — scriind, ștergând, făcând zoom și trăgând pe iPad-ul Pro de pe birou.

Dispozitivul a detectat trei bug-uri pe care toate verificările automate le-au ratat. La un zoom de peste 100%, tușele erau desenate de două ori — textura moale dedesubt, sprite-uri ascuțite deasupra — ceea ce se vedea ca un blur ușor pe care omul îl observa în câteva secunde. Confirmarea unei tușe pâlpâia timp de un cadru, deoarece vechea suprapunere făcea cross-fade în afara sincronizării cu actualizarea texturii. Și la un zoom de peste 170%, fiecare notiță dispărea: dreptunghiul de culling al vizibilității folosea contentOffset în spațiul său de coordonate scalat, așa că se îndepărta de tușe pe măsură ce făceai zoom. Fiecare dintre cele trei probleme a fost rezolvată prin modificarea unei singure linii dintr-o singură funcție și niciuna nu exista în vreun test pe care l-am fi putut scrie dinainte, pentru că nu știam să le căutăm. Pentru o aplicație de consum orientată pe UI, de aceea omul rămâne în buclă: nu poți enumera modurile în care se strică „feel-ul”.

Cum e să lucrezi cu Kimi K3

Rapid, în primul rând. Bucla „discută, scrie, construiește, instalează, privește” rula în câteva minute, iar un model care răspunde rapid schimbă câte bucle îți permiți într-o zi.

În al doilea rând, nu face over-engineering. Acest codebase rulează pe reguli de casă explicite — nicio schelă de backward-compatibility înainte de lansare, complexitate doar când un dispozitiv dovedește că este necesară — iar K3 le urmează fără să i se reamintească. Nu a adăugat indecși spațiali „pentru mai târziu”, nu a învelit fiecare apel în verificări defensive, nu a abstractizat speculativ. Să-l promptești se simte ca și cum ai lucra cu un coleg competent care a citit regulile casei și chiar crede în ele.

În al treilea rând, dă-i unelte și le folosește cu poftă. Am conectat utilități pentru imagini — vizualizare, decupare la o regiune de pixeli, redimensionare — iar modelul a început să-și decupeze proactiv propria ieșire de renderer pentru a diagnostica cele cinci bug-uri de harness de mai sus. Simplul fapt că avea instrumentul i-a amintit să privească.

Cealaltă jumătate: K3 a scris cea mai mare parte din bug-urile din această poveste, inclusiv pe cel de spațiu de coordonate care făcea notițele să dispară. Limitele lui sunt reale. Ceea ce a făcut munca sigură nu a fost niciodată faptul că modelul avea dreptate; a fost harness-ul care prindea devierea de randare și omul care prindea feel-ul. Și totuși, de la o zi la alta, nu-l puteam deosebi în mod fiabil de modelele închise de vârf pe care le folosim și noi — sisteme de clasă Opus. În anumite privințe era clar mai bun: mai rapid și mult mai puțin înclinat să umfle codebase-ul cu design defensiv.

Cum vrem să construim de acum înainte

Am terminat cu dezvoltarea agentică pe baza specificațiilor mari — stilul în care dai unui model o specificație mare și accepți orice ajunge la final. Modul de eșec nu este codul prost; este codul pe care nimeni nu-l înțelege.

Ce a funcționat aici și ce vom păstra: pași mici, fiecare discutat înainte de a începe, fiecare înțeles de om înainte de a fi construit, fiecare verificat pe dispozitivul pe care va trăi. Rolul modelului este să fie rapid, precis și sincer despre incertitudine. Rolul omului este judecata, gustul și verificarea e2e — mai ales pentru software de consum orientat pe UI, unde specificația nu poate descrie cum se simte „corect”. Kimi K3 pe Fireworks se dovedește bine adaptat exact pentru această buclă: suficient de rapid ca să țină bucla strânsă, suficient de deștept ca să țină pașii mici și curați.

Unda a dispărut, pipeline-ul este o singură idee în loc de două, iar procesul care ne-a adus acolo rămâne.