Перестройка конвейера рендеринга Lulucat Notes вместе с Kimi K3
Наш тайловый конвейер Core Graphics превратился в конвейер точечных спрайтов Metal за шесть небольших шагов, каждый из которых проверен на реальном iPad. Код был написан в парном программировании с Kimi K3 на Fireworks.
На прошлой неделе перетаскивание лассо-выделения в Lulucat Notes порождало видимую волну: в одном и том же кадре одни экранные тайлы показывали выделение в новом положении, а другие всё ещё показывали старое. Мы заменили весь конвейер рендеринга — bitmap Core Graphics плюс CATiledLayer — на Metal, в шесть небольших шагов, каждый из которых проверялся на реальном iPad до начала следующего.
Ещё одна деталь об авторстве, важная для второй половины поста: код был написан в парном программировании с Kimi K3, открытой моделью Moonshot, запущенной на Fireworks. Человек управлял, принимал решения и тестировал; модель написала почти каждую строку.
Конвейер
В старом конвейере было два вида отрисовки, которые медленно расходились: штрихи запекались в bitmap для дешёвого отображения, а затем перерисовывались как векторы, когда тайлам требовалась большая детализация. В новом конвейере ровно одна идея: всё, что похоже на чернила, — это точечный спрайт. Штрих пера, мазок текстовыделителя и касание ластика — это одна и та же вершина размером 32 байта — положение, диаметр, цвет, — отрисовываемая одной и той же парой шейдеров как растеризованные на GPU окружности, расположенные с шагом в одну точку вдоль длины дуги штриха.
![]()
При шаге в одну точку цепочка окружностей отклоняется от математически идеальной капсулы примерно на 0.075 точки — пятую часть пикселя при плотности нашего холста. В обмен три инструмента сводятся к одному пути кода, а GPU занимается тем, что у него получается лучше всего.
Вокруг этой идеи архитектура проста. Зафиксированные чернила хранятся в единственной текстуре 4096². UIScrollView остаётся, пониженный до чистого движка жестов: его contentOffset и zoomScale каждый кадр передаются в uniform области просмотра, поэтому панорамирование и масштабирование ничего не записывают. Каждый кадр — это пять отрисовок:
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
Операции редактирования записывают текстуру напрямую. Фиксация штриха добавляет его штампы. Стирание, удаление, перемещение и отмена действий воспроизводят затронутую область с помощью scissor rectangle: очистить область, перерисовать пересекающие её штрихи, готово. Частичное стирание сохраняет семантику владения из предыдущего поста — стирание принадлежит штриху, у которого оно удаляет чернила, — путём отрисовки каждого стираемого штриха в scratch-текстуру, вычитания его собственных путей стирания с помощью блендинга destination-out (
Выделение, с которого всё началось, теперь тоже отрисовывается как точечные спрайты. Его перетаскивание обновляет одно смещение uniform. Ноль записей в текстуру, ноль инвалидаций тайлов — волна устранена структурно, а не просто смягчена.
Контролёр: пиксельный дифф
Мы не удаляли рендерер Core Graphics. Мы понизили его до офлайн-эталонной реализации, и каждое изменение Metal должно проходить пиксельное сравнение с ней на реальных данных штрихов, записанных на устройстве. Критерий приёмки — не «идентичные пиксели»: два корректных растеризатора закономерно расходятся на несколько уровней серого вдоль антиалиасных краёв. Критерий структурный: нет пропущенных чернил, нет смещения, нет цветового дрейфа и нет большой разницы где-либо вдали от чернил.
![]()
Этот harness поймал четыре из пяти багов, с которыми мы столкнулись при создании офлайн-рендерера; все они были диагностированы путём кадрирования вывода до пиксельного уровня: несовпадение stride у структуры Swift/Metal (28 байт против 32, потому что Metal выравнивает float4 по 16 — экран заполнился цветовыми блоками), атрибут [[point_size]] нельзя было прочитать как varying в фрагментном шейдере (каждый штамп получался квадратным), два render-энкодера, сосуществующих в одном command buffer (всё чёрное), и отсутствующий стартовый штамп, из-за которого первый миллиметр быстрых штрихов был невидимым.
Шесть шагов, а не одно переписывание
План миграции состоял из шести независимо поставляемых шагов: офлайн-рендерер, проходящий пиксельный дифф; оболочка отображения без визуальных изменений; живой штрих на GPU; выделение на GPU; мутации, записывающие текстуру напрямую; перерисовка векторами при сильном увеличении (встроенная во второй шаг, поскольку «ноль визуальных изменений» этого требовало). Каждый шаг завершался тем, что человек — не симулятор, не diff скриншота — писал, стирал, увеличивал и перетаскивал на iPad Pro, лежащем на столе.
Устройство обнаружило три бага, которые не заметила ни одна автоматическая проверка. При увеличении выше 100% штрихи отрисовывались дважды — мягкая текстура внизу, резкие спрайты сверху, — что читалось как лёгкое размытие, которое человек замечал за секунды. Фиксация штриха мерцала в течение одного кадра, потому что старый оверлей выполнял cross-fade не синхронно с обновлением текстуры. А при увеличении выше 170% каждая заметка исчезала: прямоугольник отсечения видимости использовал contentOffset в своём масштабированном координатном пространстве, поэтому при масштабировании он уплывал от штрихов. Все три проблемы исправлялись изменением одной строки в одной функции, и ни одной из них не было ни в одном тесте, который мы могли бы написать заранее, потому что мы не знали, что их нужно искать. Именно поэтому человек остаётся в цикле в потребительском приложении, где UI — главное: невозможно перечислить все способы, которыми ломается «ощущение».
Каково работать с Kimi K3
Прежде всего — быстрый. Цикл «обсудить, написать, собрать, установить, посмотреть» занимал минуты, а модель, которая отвечает быстро, меняет то, сколько циклов вы можете себе позволить за день.
Во-вторых, он не переусложняет. Эта кодовая база работает по явным внутренним правилам — никаких каркасов обратной совместимости до запуска, сложность — только когда устройство доказывает её необходимость, — и K3 следует им без напоминаний. Он не добавлял пространственные индексы «на потом», не оборачивал каждый вызов защитными проверками, не абстрагировал умозрительно. Работа с ним ощущается как сотрудничество с компетентным коллегой, который прочитал внутренние правила и действительно верит в них.
В-третьих, дайте ему инструменты, и он использует их охотно. Мы подключили утилиты для работы с изображениями — просмотр, кадрирование до пиксельной области, изменение размера, — и модель начала проактивно кадрировать вывод собственного рендерера, чтобы диагностировать пять багов harness, описанных выше. Наличие инструмента напоминало ей о необходимости смотреть.
Обратная сторона: K3 написал большую часть багов в этой истории, включая тот, с координатным пространством, из-за которого заметки исчезали. Его ограничения реальны. Безопасность работы обеспечивало не то, что модель оказывалась права; её обеспечивал harness, ловящий дрейф рендеринга, и человек, улавливающий ощущение. И тем не менее изо дня в день я не мог надёжно отличить его от закрытых моделей переднего края, которыми мы также пользуемся, — систем класса Opus. По некоторым осям он был откровенно лучше: быстрее и гораздо меньше склонен раздувать кодовую базу защитным проектированием.
Как мы хотим строить дальше
Мы покончили с агентной разработкой по большим спецификациям — стилем, при котором вы передаёте модели большую спецификацию и принимаете всё, что получается. Режим сбоя здесь — не плохой код; это код, который никто не понимает.
Здесь сработало, и это мы сохраним: небольшие шаги, каждый из которых обсуждается до начала, каждый понимается человеком до того, как будет построен, каждый проверяется на устройстве, на котором будет жить. Задача модели — быть быстрой, точной и честной относительно неопределённости. Задача человека — оценка, вкус и сквозная (e2e) проверка, особенно для потребительского ПО, где UI — главное, ведь спецификация не может описать, как должно ощущаться «правильно». Kimi K3 на Fireworks оказывается хорошо подходящим именно для этого цикла: достаточно быстрым, чтобы держать цикл коротким, и достаточно умным, чтобы шаги оставались маленькими и чистыми.
Волна исчезла, конвейер — это одна идея вместо двух, и процесс, который привёл нас сюда, остаётся.