Перебудова конвеєра рендерингу Lulucat Notes за допомогою Kimi K3
Наш тайловий конвеєр Core Graphics за шість невеликих кроків перетворився на конвеєр точкових спрайтів Metal, і кожен крок перевірено на справжньому iPad. Код створено в парному програмуванні з Kimi K3 на Fireworks.
Минулого тижня перетягування ласо-вибору в Lulucat Notes створювало помітну хвилю: одні екранні тайли показували вибір у новій позиції, тоді як інші все ще показували стару — у тому самому кадрі. Ми замінили весь конвеєр рендерингу — бітмапу Core Graphics разом із CATiledLayer — на Metal за шість невеликих кроків; кожен крок перевіряли на справжньому iPad, перш ніж почати наступний.
Ще одна деталь про авторство важлива для другої половини цього допису: код створено в парному програмуванні з Kimi K3, відкритою моделлю Moonshot, що працює на Fireworks. Людина керувала, приймала рішення й тестувала; модель написала майже кожен рядок.
Конвеєр
Старий конвеєр мав два види малювання, які поступово розходилися: штрихи запікалися в бітмапу для дешевого відображення, а щоразу, коли тайлам потрібно було більше деталей, їх перемальовували як вектори. Новий конвеєр має рівно одну ідею: усе, що схоже на чорнило, є точковим спрайтом. Штрих пера, мазок маркера та дотик гумки — це одна й та сама 32-byte vertex — position, diameter, color — яку та сама пара шейдерів малює як растеризовані на GPU кола, розташовані на відстані однієї точки вздовж довжини дуги штриха.
![]()
При відстані в одну точку ланцюг кіл відхиляється від математично досконалого капсуля приблизно на 0.075 points — п’яту частину пікселя при нашій щільності полотна. Натомість три інструменти зводяться до одного code path, а GPU робить те, що вміє найкраще.
Навколо цієї ідеї архітектура проста. Збережене чорнило міститься в одній texture 4096². UIScrollView залишається, але його зведено до чистого gesture engine: його contentOffset і zoomScale подаються у viewport uniform кожного кадру, тож pan і zoom нічого не записують. Кожен кадр — це п’ять малювань:
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
Редагування записують безпосередньо в texture. Commit штриха додає його stamps. Erase, delete, move й undo відтворюють уражену ділянку за scissor rectangle: очистити ділянку, перемалювати штрихи, що її перетинають, і готово. Часткове стирання зберігає семантику власності з попереднього допису — стирання належить штриху, з якого воно прибирає чорнило — малюючи кожен стертий штрих у scratch texture, віднімаючи його власні шляхи стирання через destination-out blending (
Вибір, з якого все почалося, тепер також малюється як точкові спрайти. Його перетягування оновлює один uniform offset. Нуль записів у texture, нуль інвалідацій тайлів — хвиля структурно зникла, а не була просто пом’якшена.
Страж: піксельний diff
Ми не видалили renderer Core Graphics. Ми звели його до offline reference implementation, і кожна зміна в Metal має пройти pixel comparison з ним на реальних даних штрихів, захоплених на пристрої. Критерій прийняття — не «ідентичні пікселі»: два коректні растеризатори закономірно розходяться на кілька рівнів сірого вздовж згладжених країв. Цей поріг визначається структурою: немає пропущеного чорнила, немає зсуву, немає дрейфу кольору й немає великої різниці будь-де поза чорнилом.
![]()
Цей harness виявив чотири з п’яти багів, на які ми натрапили під час створення offline renderer, усі діагностовані після обрізання виводу до піксельного рівня: невідповідність stride у struct Swift/Metal (28 bytes проти 32, бо Metal вирівнює float4 до 16 — екран заповнився кольоровими блоками), [[point_size]] не можна було прочитати як varying у fragment shader (кожен відбиток виходив квадратним), два render encoder співіснували на одному command buffer (усе чорне), а пропущений start dab залишав перший міліметр швидких штрихів невидимим.
Шість кроків, а не один перепис
План міграції складався із шести кроків, кожен з яких можна випускати окремо: offline renderer, що проходить pixel diff; display shell із нульовою візуальною зміною; live stroke на GPU; вибір на GPU; mutations, що пишуть texture напряму; vector re-drawing при високому zoom (включено до другого кроку, бо «zero visual change» цього вимагало). Кожен крок завершувався тим, що людина на iPad Pro на столі писала, стирала, збільшувала й перетягувала. Не симулятор і не screenshot diff, а людина за пристроєм.
Пристрій виявив три баги, які пропустила кожна автоматизована перевірка. Понад 100% zoom штрихи малювалися двічі — м’яка texture знизу, різкі sprites зверху — і це виглядало як слабке розмиття, яке людина помічала за секунди. Commit штриха блимав один кадр, бо старий overlay виконував cross-fade не синхронно з оновленням texture. А понад 170% zoom кожна нотатка зникала: visibility-culling rectangle використовував contentOffset у своєму масштабованому coordinate space, тож під час zoom віддалявся від штрихів. Усі три проблеми виправлялися змінами від одного рядка до однієї функції, і жодної не було в тесті, який ми могли написати заздалегідь, бо ми не знали, що їх шукати. Для UI-first consumer app саме тому людина залишається в loop.
Яким є працювати з Kimi K3
Передусім вона швидка. Цикл «discuss, write, build, install, look» тривав хвилинами, а модель, що швидко відповідає, змінює те, скільки циклів ви можете собі дозволити за день.
По-друге, вона не ускладнює зайвого. Ця кодова база працює за явними house rules — жодних backward-compatibility scaffolding до launch, complexity лише тоді, коли device доводить її необхідність — і K3 дотримується їх без нагадування. Вона не додавала spatial indexes «на потім», не обгортала кожен call у defensive checks, не абстрагувала спекулятивно. Промптувати її — це як працювати з компетентною колегою, яка прочитала house rules і справді вірить у них.
По-третє, дайте їй tools, і вона охоче ними користуватиметься. Ми підключили image utilities — view, crop до pixel region, resize — і модель почала проактивно обрізати власний renderer output, щоб діагностувати п’ять harness bugs вище. Наявність інструменту нагадала їй, що треба дивитися.
Інша половина: K3 написала більшість багів у цій історії, включно з coordinate-space bug, через який зникали нотатки. Її обмеження реальні. Роботу зробило безпечною не те, що модель мала рацію, а harness, який ловив rendering drift, і людина, яка ловила feel. І все ж у щоденній роботі я не міг надійно відрізнити її від frontier closed models, якими ми також користуємося, — систем класу Opus. У деяких вимірах вона була явно кращою: швидшою і значно менш схильною наповнювати codebase defensive design.
Як ми хочемо будувати відтепер
Ми покінчили з agentic development за великими специфікаціями — стилем, коли ви даєте моделі велику specification і приймаєте все, що з неї вийде. Failure mode — не поганий код, а код, якого ніхто не розуміє.
Те, що спрацювало тут і що ми збережемо: невеликі кроки, кожен обговорений до початку, кожен зрозумілий людиною до побудови, кожен перевірений на device, на якому житиме. Завдання моделі — бути швидкою, точною та чесною щодо невизначеності. Завдання людини — judgment, taste і e2e verification, особливо для UI-first consumer software, де specification не може описати, яким є «правильно». Kimi K3 на Fireworks виявилася добре пристосованою саме до цього loop: достатньо швидкою, щоб тримати loop щільним, і достатньо розумною, щоб тримати кроки невеликими та чистими.
Хвиля зникла, конвеєр став однією ідеєю замість двох, а процес, який привів нас сюди, залишається.