Производительность инструмента мела: от полноэкранных проходов до scissor-прямоугольников
Инструмент мела в Lulucat Notes замедлялся в областях с плотным рукописным вводом. Узким местом были не 3,571 входных образцов — им оказались 70 полноэкранных scratch-проходов на каждый кадр. Отклонённый кэш низкого разрешения и scissor-прямоугольник для каждого штриха дополняют эту историю.

Красный и синий мел на финальной сборке устройства, 155 штрихов при увеличении 255%.
У инструмента мела в Lulucat Notes была конкретная проблема с производительностью: на пустом месте писать было плавно, но при переходе в область, уже заполненную меловыми штрихами, кончик пера начинал отставать. Продолжение письма в той же области постепенно замедляло и панорамирование холста.
Чтобы воспроизвести проблему, хватало одной страницы обычного рукописного текста: увеличение 300%, 70 видимых меловых штрихов в локальной области, всего 3,571 точка входных образцов. Пустые области оставались плавными; замедлялась только область, где штрихи были сосредоточены.
После исправления та же страница может продолжать принимать новые записи при увеличении 255%, а существующие штрихи сохраняют полную чёткость и при касании пером, и при панорамировании.

Финальный снимок экрана устройства, всего 155 штрихов. При таком увеличении ни касание пером, ни панорамирование временно не меняют чёткость существующих штрихов.
Зачем мелу нужна scratch-текстура
Обычное перо может напрямую композитить каждый круглый штамп с текстурой чернил в режиме source-over. Мел добавляет слой управления зерном: сначала рендерер накапливает покрытие тела и глубину для всего штриха, затем с помощью фиксированной текстуры зерна определяет, какие позиции получат меловую пыль, и наконец композитит результат с существующими чернилами.
Эта scratch-текстура изолирует один меловой штрих. Изоляция важна, потому что штампы внутри одного штриха сильно перекрываются; если пропускать каждый штамп через фильтр зерна отдельно, центральная линия штриха многократно накапливала бы цвет, а меловые поры менялись бы вместе с плотностью выборки.
При большом увеличении Lulucat Notes перерисовывает видимые в текущем viewport векторные штрихи. Старая реализация выполняла для каждого видимого мелового штриха следующие шаги:
- Завершить основной render encoder;
- Очистить scratch-текстуру;
- Нарисовать этот меловой штрих в scratch;
- Снова открыть основной render encoder;
- Скомпозитить scratch обратно в drawable с помощью полноэкранного треугольника.
Семантика одного штриха была правильной, но объём работы был намного больше. Drawable iPad имел размер 2732×2048 — примерно 5.6 million pixels. Каждый меловой штрих запускал один scratch-проход и одну полноэкранную композицию. Семьдесят меловых штрихов означали примерно 141 render encoders и 70 полноэкранных композиций.
Пусть
У каждого мелового штриха был и фиксированный накладной расход render pass, поэтому эта стоимость тоже росла линейно с
Измерения проводились на 12.9-inch iPad Pro (5th generation, M1) под управлением iPadOS 18.6.2. Мы сравнили GPU timestamps одного и того же viewport до и после изменения, используя command-buffer timestamps в той же Debug device build на этом конкретном iPad — далее LucasPad. Приведённые ниже диапазоны — обычные колебания в логах за много кадров, а не обязательства по частоте кадров поставляемой версии. При 70 видимых меловых штрихах один кадр обычно требовал 52–60 ms времени GPU; в области примерно со 120 штрихами время GPU возрастало до 77–80 ms.
Если оценивать работу по площади полноэкранных прямоугольников scratch- и composite-проходов, теоретический объём работы за кадр вырос примерно с 783 million pixels до 1.34 billion pixels. Это число представляет собой сумму площадей прямоугольников и не равно числу вызовов фрагментов, байтам чтения/записи видеопамяти или аппаратным счётчикам GPU. Fast clear, attachment load/store и переключение pass-ов в Metal остаются под контролем GPU и драйвера.
Это также объясняет, почему пустые области оставались плавными. Visibility culling пропускает штрихи за пределами viewport; в пустой области
Неправильный ответ за 0.85 ms
В приложении уже была запечённая текстура чернил всей страницы с разрешением два пикселя на точку. Мы попробовали напрямую показывать эту текстуру во время письма, панорамирования и масштабирования, оставляя только текущий штрих Apple Pencil живым вектором; после завершения взаимодействия ещё один кадр заново отрисовывал векторный результат высокого разрешения.
Этот подход работал очень хорошо. В той же плотной области при увеличении 300% время GPU снизилось до 0.84–0.85 ms и перестало расти вместе с числом существующих меловых штрихов.
На настоящем устройстве проблема была столь же очевидной. При увеличении 300% требовалось примерно шесть экранных пикселей на точку, но кэш предоставлял только два. В момент касания Apple Pencil все существующие штрихи превращались в мягкое изображение низкого разрешения; после отрыва Pencil они возвращались к полной чёткости.
Тестер сказал одну вещь: «Когда я пишу, весь холст размывается. Как только отпускаю, он снова становится чётким».
Оптимизацию удалили. 0.85 ms было самым низким измеренным результатом, но приемлемым инструментом мела оно не было. Существующие штрихи — часть обратной связи при письме; их чёткость не может меняться при касании пером.
Ограничение каждого мелового штриха его собственным прямоугольником
Финальное исправление сохранило scratch для каждого штриха и композитинг для каждого штриха, уменьшив только объём работы с пикселями. Для каждого штриха уже существовала canvas bounding box, полученная объединением радиусов всех его штампов. Рендерер преобразует эту bounding box в координаты drawable текущего viewport и добавляет два пикселя для поля сглаживания:
Один и тот же scissor-прямоугольник используется для трёх операций: очистки scratch, рисования штриха и композитинга результата обратно на основную поверхность.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Та же логика используется при baking и partial replay на текстуре чернил 4096², поэтому отображение при большом увеличении и устоявшийся слой чернил не создают два разных поведения мела.
Здесь легко не заметить две детали.
Во-первых, loadAction = .clear у render pass выполняется на этапе загрузки attachment и не ограничивается scissor растеризации. Если продолжать его использовать, он всё равно очистит всю scratch-текстуру. Исправленный pass использует .dontCare, а затем рисует clear_fragment внутри scissor. Этот прямоугольник впоследствии записывается полностью, а composite читает только тот же прямоугольник, поэтому старое содержимое attachment загружать не нужно.
Во-вторых, после завершения композиции каждого мелового штриха внешний scissor нужно восстановить. Если пропустить эту строку восстановления состояния, последующие перья, изображения или выделения продолжат обрезаться границами предыдущего мелового штриха и будут выглядеть как пропавшие штрихи или изображения.
Меловое зерно по-прежнему выбирается по абсолютным координатам canvas, а не по локальным UV. Перемещение scissor меняет только набор пикселей, обрабатываемых GPU; оно не меняет расположение текстуры зерна, из которого читает каждый пиксель. Поэтому соседние прямоугольники не создают швов текстуры, а перетаскивание холста не заставляет зерно смещаться.
Если учитывать только нагрузку на пиксели, новый объём работы близок к
где
Почему меловые штрихи одного цвета не объединили в один пакет
Большинство меловых штрихов на странице имеют одинаковые цвет и плотность, поэтому заманчиво рисовать десятки штрихов в scratch одновременно и выполнять композицию только один раз. Это ещё сильнее сократило бы число render passes, но изменило бы семантику цвета и зерна в местах перекрытия.
Рассмотрим намеренно упрощённый случай: два штриха имеют в одном пикселе одинаковое значение grain-gate
тогда как сначала объединённые тела с последующим применением одной gate дают
Разница равна
Точное объединение требует доказать, что пиксели штрихов взаимно не пересекаются, либо выделить для каждого штриха независимую область atlas и композитить в исходном порядке. Финальная приёмка на устройстве сохранила scissor-подход, поэтому в этом раунде мы не добавляли atlas и сложность его управления.
От миллиарда пикселей обратно к нескольким миллионам
Финальные измерения на устройстве:
| Сценарий | До исправления | Точный scissor |
|---|---|---|
| 70 видимых меловых штрихов, увеличение 300%, письмо | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 видимый меловой штрих, увеличение 300% | GPU 77–80 ms | 13.7–15.6 ms |
| Теоретический объём прямоугольников на кадр (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
Мы также проверили границы clipping, используя 3,452 штриха и 202,710 точек образцов из документа на устройстве. При увеличении 0.5×, 1×, 2×, 3×, 5× и 8× было создано 186,408 случаев viewport; каждый point sprite, способный дать ненулевое покрытие, оказался внутри вычисленного scissor. Проверка охватила края canvas, края viewport и различные комбинации offset.
Финальный код не переключается на LOD низкого разрешения в зависимости от состояния взаимодействия. На малых уровнях zoom по-прежнему показывается текстура чернил всей страницы; на больших уровнях zoom видимые штрихи по-прежнему перерисовываются как векторы. На одной и той же стороне порога касание пером и панорамирование не заменяют существующие штрихи уровнем чёткости другого качества. Во время векторной перерисовки при большом увеличении очистка scratch и композиция каждого мелового штриха покрывают только его собственную экранную bounding box.
Время GPU не отражает чёткость
Проблемы производительности GPU не обязательно масштабируются с самой заметной величиной в структуре данных. В этом случае 3,571 входная точка была очевидным подозреваемым; время кадра определялось полноэкранной работой, которую запускал каждый из 70 меловых штрихов, вместе с переключением render-pass.
Визуальная семантика также ограничивала доступные оптимизации. Scratch для каждого штриха, исходный порядок композиции и абсолютные координаты зерна canvas нельзя было произвольно удалить. Одинаковые цвет и плотность означают только совпадение параметров — они не доказывают, что перекрывающиеся результаты можно объединить.
Отзыв с реального устройства отверг версию с минимальным временем GPU. Фраза «весь холст становится размытым» выразила продуктовое ограничение, которого одних измерений было недостаточно: когда Apple Pencil касается поверхности, пользователь одновременно наблюдает и существующие штрихи.
Финальная версия не добавляет новый слой кэша взаимодействия и не снижает чёткость. Векторная перерисовка при большом увеличении просто ограничивает работу каждого мелового штриха его собственной экранной bounding box. После повторной загрузки на LucasPad отзыв стал таким: «Выглядит отлично».