Przebudowa potoku renderowania Lulucat Notes z użyciem Kimi K3
Nasz kafelkowy potok Core Graphics zmienił się w potok sprite'ów punktowych w Metalu w sześciu małych krokach, z których każdy był sprawdzany na prawdziwym iPadzie. Kod powstawał w parze z Kimi K3 na Fireworks.
W zeszłym tygodniu przeciąganie zaznaczenia lassa w Lulucat Notes wywoływało widoczną falę: w tej samej klatce niektóre kafelki ekranu pokazywały zaznaczenie w nowej pozycji, podczas gdy inne wciąż pokazywały starą. Zastąpiliśmy cały potok renderowania — bitmapę Core Graphics wraz z CATiledLayer — Metalem, w sześciu małych krokach, z których każdy był sprawdzany na prawdziwym iPadzie, zanim rozpoczął się następny.
Jeszcze jedna rzecz o autorstwie, bo ma znaczenie dla drugiej połowy tego wpisu: kod powstawał w parze z Kimi K3, otwartym modelem firmy Moonshot uruchomionym na Fireworks. Człowiek prowadził, podejmował decyzje i testował; model napisał niemal każdą linijkę.
Potok
Stary potok miał dwa sposoby rysowania, które powoli się rozchodziły: pociągnięcia były wypalane w bitmapie na potrzeby taniego wyświetlania, a gdy kafelki potrzebowały większej szczegółowości, rysowano je ponownie jako wektory. Nowy potok opiera się na dokładnie jednej idei: wszystko, co wygląda jak tusz, jest sprite’em punktowym. Pociągnięcie pióra, przeciągnięcie zakreślaczem i muśnięcie gumką to ten sam wierzchołek o rozmiarze 32 bajtów — pozycja, średnica, kolor — rysowany przez tę samą parę shaderów jako rasteryzowane na GPU koła, rozstawione co jeden punkt wzdłuż długości łuku pociągnięcia.
![]()
Przy odstępie jednego punktu łańcuch kół odchyla się od matematycznie idealnej kapsuły o około 0.075 punktu — piątą część piksela przy naszej gęstości płótna. W zamian trzy narzędzia sprowadzają się do jednej ścieżki kodu, a GPU robi to, w czym jest najlepsze.
Wokół tej idei architektura jest prosta. Zatwierdzony tusz mieści się w jednej teksturze 4096². UIScrollView przetrwał, zepchnięty do roli czystego silnika gestów: jego contentOffset i zoomScale zasilają co klatkę uniform widoku, więc przesuwanie i powiększanie niczego nie zapisują. Każda klatka to pięć rysowań:
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
Edycje zapisują bezpośrednio do tekstury. Zatwierdzenie pociągnięcia dopisuje jego stemple. Wymazywanie, usuwanie, przenoszenie i cofanie odtwarzają dotknięty obszar z użyciem prostokąta scissor: wyczyść obszar, narysuj ponownie pociągnięcia, które go przecinają, gotowe. Częściowe wymazywanie zachowuje semantykę przynależności z poprzedniego wpisu — wymazanie należy do pociągnięcia, z którego usuwa tusz — poprzez narysowanie każdego wymazywanego pociągnięcia do tekstury scratch, odjęcie jego własnych ścieżek wymazywania mieszaniem destination-out (
Zaznaczenie, od którego to wszystko się zaczęło, jest teraz również rysowane jako sprite’y punktowe. Jego przeciąganie aktualizuje jeden offset uniformu. Zero zapisów do tekstury, zero unieważnień kafelków — fala zniknęła strukturalnie, a nie została tylko złagodzona.
Strażnik: różnica pikseli
Nie usunęliśmy renderera Core Graphics. Zepchnęliśmy go do roli referencyjnej implementacji offline i każda zmiana w Metalu musi przejść porównanie pikseli z nią, na prawdziwych danych pociągnięć zarejestrowanych na urządzeniu. Kryterium akceptacji nie brzmi „identyczne piksele” — dwa poprawne rasteryzatory mogą się słusznie różnić o kilka poziomów szarości wzdłuż krawędzi z antyaliasingiem. Bramka ma charakter strukturalny: nie brakuje tuszu, nie ma przesunięcia ani dryfu koloru i nie ma dużej różnicy nigdzie poza tuszem.
![]()
Ten harness wyłapał cztery z pięciu błędów, jakie napotkaliśmy przy budowaniu renderera offline; wszystkie zdiagnozowaliśmy przez kadrowanie wyjścia do poziomu pikseli: niezgodność kroku (stride) struktury Swift/Metal (28 bajtów wobec 32, ponieważ Metal wyrównuje float4 do 16 — ekran wypełnił się kolorowymi blokami), nieczytelność [[point_size]] jako varying w shaderze fragmentów (każdy stempel wychodził kwadratowy), współistnienie dwóch koderów renderowania na jednym buforze poleceń (wszystko było czarne) oraz brakujący stempel początkowy, przez który pierwszy milimetr szybkich pociągnięć był niewidoczny.
Sześć kroków, a nie jedno przepisanie
Plan migracji obejmował sześć niezależnie wdrażalnych kroków: renderer offline przechodzący różnicę pikseli; powłoka wyświetlania bez zmian wizualnych; pociągnięcie na żywo na GPU; zaznaczenie na GPU; mutacje zapisujące bezpośrednio do tekstury; ponowne rysowanie wektorowe przy dużym przybliżeniu (włączone w drugi krok, bo „zero zmian wizualnych” tego wymagało). Każdy krok kończył się tym, że człowiek — nie symulator, nie diff zrzutu ekranu — pisał, wymazywał, powiększał i przeciągał na iPadzie Pro leżącym na biurku.
Urządzenie wykryło trzy błędy, których nie wykryła żadna automatyczna kontrola. Powyżej 100% przybliżenia pociągnięcia były rysowane dwa razy — miękka tekstura pod spodem, ostre sprite’y na wierzchu — co jawiło się jako lekkie rozmycie, dostrzeżone przez człowieka w kilka sekund. Zatwierdzenie pociągnięcia migotało przez jedną klatkę, ponieważ stara nakładka wygaszała się w przejściu krzyżowym niezgodnie z aktualizacją tekstury. A powyżej 170% przybliżenia znikały wszystkie notatki: prostokąt odrzucania niewidocznych używał contentOffset we własnej przeskalowanej przestrzeni współrzędnych, więc oddalał się od pociągnięć w miarę powiększania. Każdy z tych trzech problemów dało się naprawić zmianą jednej linii w jednej funkcji i żadnej z nich nie było w żadnym teście, jaki moglibyśmy napisać wcześniej, bo nie wiedzieliśmy, że trzeba ich szukać. W aplikacji konsumenckiej z naciskiem na interfejs właśnie dlatego człowiek pozostaje w pętli: nie da się wyliczyć sposobów, na jakie psuje się „odczucie”.
Jak się pracuje z Kimi K3
Przede wszystkim jest szybki. Pętla „omów, napisz, zbuduj, zainstaluj, obejrzyj” trwała kilka minut, a szybko odpowiadający model zmienia liczbę pętli, na które możesz sobie pozwolić w ciągu dnia.
Po drugie, nie przeinżynierowuje. Ta baza kodu działa według jawnych wewnętrznych reguł — żadnego rusztowania kompatybilności wstecznej przed premierą, złożoność tylko wtedy, gdy urządzenie udowodni jej konieczność — a K3 przestrzega ich bez przypominania. Nie dodawał indeksów przestrzennych „na później”, nie otaczał każdego wywołania defensywnymi kontrolami, nie abstrahował spekulatywnie. Promptowanie go przypomina pracę z kompetentnym kolegą, który przeczytał wewnętrzne reguły i naprawdę w nie wierzy.
Po trzecie, daj mu narzędzia, a chętnie z nich korzysta. Podłączyliśmy narzędzia obrazowe — podgląd, kadrowanie do obszaru pikseli, zmianę rozmiaru — a model zaczął proaktywnie kadrować własne wyjście renderera, by zdiagnozować pięć powyższych błędów harnessu. Samo posiadanie narzędzia przypominało mu, żeby spojrzał.
Druga strona medalu: K3 napisał większość błędów w tej historii, w tym ten z przestrzenią współrzędnych, przez który notatki znikały. Jego ograniczenia są realne. Tym, co czyniło pracę bezpieczną, nigdy nie było to, że model ma rację; to harness wyłapujący dryf renderowania oraz człowiek wyłapujący odczucie. A jednak na co dzień nie potrafiłem niezawodnie odróżnić go od zamkniętych modeli najnowszej generacji, których także używamy — systemów klasy Opus. Pod pewnymi względami był wyraźnie lepszy: szybszy i znacznie mniej skłonny do rozdmuchiwania bazy kodu defensywnym projektowaniem.
Jak chcemy budować od teraz
Kończymy z programowaniem agentowym opartym na obszernych specyfikacjach — stylem, w którym wręcza się modelowi dużą specyfikację i przyjmuje, cokolwiek z tego wyjdzie. Trybem awarii nie jest zły kod; to kod, którego nikt nie rozumie.
To, co zadziałało tutaj i co zachowamy: małe kroki, każdy omawiany, zanim się zacznie, każdy rozumiany przez człowieka, zanim zostanie zbudowany, każdy sprawdzany na urządzeniu, na którym będzie żył. Zadaniem modelu jest być szybki, precyzyjny i szczery wobec niepewności. Zadaniem człowieka jest osąd, wyczucie i weryfikacja end-to-end — zwłaszcza przy oprogramowaniu konsumenckim z naciskiem na interfejs, gdzie specyfikacja nie potrafi opisać, jak „dobrze” się czuje. Kimi K3 na Fireworks okazuje się doskonale dopasowany do tej pętli: wystarczająco szybki, by utrzymać pętlę krótką, wystarczająco inteligentny, by utrzymać kroki małymi i czystymi.
Fala zniknęła, potok opiera się na jednej idei zamiast dwóch, a proces, który nas tam doprowadził, zostaje.