Lulucat

Продуктивність інструмента крейди: від повноекранних проходів до scissor rectangle

Gaoge ZhangGaoge Zhang

Інструмент крейди в Lulucat Notes сповільнився в щільних ділянках рукописного тексту. Вузьким місцем були не 3,571 вхідних зразків, а 70 повноекранних scratch pass за кадр. Відхилений кеш низької роздільності та scissor rectangle для кожного штриха пояснюють решту історії.

Обрізаний вигляд Lulucat Notes на iPad із масштабом 255%: червоною та синьою крейдою написано класичний китайський уривок, також видно частину панелі інструментів застосунку.

Червона й синя крейда у фінальній збірці для тестування на пристрої, 155 штрихів при масштабі 255%.

Інструмент крейди в Lulucat Notes мав дуже конкретну проблему з продуктивністю: письмо на порожній ділянці було плавним, але коли перо переходило на ділянку, уже заповнену крейдяними штрихами, кінчик пера починав відставати. Подальше письмо в тій самій ділянці поступово сповільнювало і панорамування полотна.

Однієї звичайної сторінки рукописного тексту було достатньо, щоб відтворити проблему: масштаб 300%, 70 видимих крейдяних штрихів у локальній ділянці, загалом 3,571 вхідних точок зразків. Порожні ділянки залишалися плавними; повільною ставала лише ділянка, де зосереджувалися ці штрихи.

Після виправлення та сама сторінка може й далі приймати нові записи при масштабі 255%, а наявні штрихи зберігають повну чіткість і під час письма, і під час панорамування.

Lulucat Notes на iPad із масштабом 255%, де показано рукопис крейдою червоного та синього кольорів. Текст читається як «天行健,君子以自强不息;地势坤,君子以厚德载物» — класичний китайський уривок. У правому верхньому куті сидить синій талісман Lulucat. Нижня панель інструментів показує кількість штрихів 155, Save, Clear і повзунок масштабу 255%.

Фінальний знімок екрана пристрою, загалом 155 штрихів. На цьому рівні масштабу чіткість наявних штрихів тимчасово не перемикається ні під час письма, ні під час панорамування.

Навіщо крейді потрібна scratch texture

Звичайне перо може композитити кожен круглий відбиток безпосередньо на текстуру чорнила за допомогою source-over blending. Крейда додає шар керування зернистістю: renderer спочатку накопичує body coverage і depth для всього штриха, потім використовує фіксовану grain texture, щоб визначити, які позиції отримають крейдяний пил, і нарешті композитить результат на наявне чорнило.

Ця scratch texture ізолює один крейдяний штрих. Ізоляція важлива, бо відбитки в межах одного штриха сильно перекриваються; якби кожен відбиток окремо проходив grain-gating, центральна лінія штриха накопичувала б повторний колір, а пори крейди змінювалися б разом із щільністю семплювання.

На великих рівнях масштабу Lulucat Notes перемальовує векторні штрихи, видимі в поточному viewport. Стара реалізація виконувала для кожного видимого крейдяного штриха такі кроки:

  1. Завершити головний render encoder;
  2. Очистити scratch texture;
  3. Намалювати цей крейдяний штрих у scratch;
  4. Знову відкрити головний render encoder;
  5. Скомпозитити scratch назад у drawable за допомогою full-screen triangle.

Семантика одного штриха була правильною, але масштаб роботи був набагато більшим. Drawable iPad мав розмір 2732×2048 — приблизно 5.6 million pixels. Кожен крейдяний штрих запускав один scratch pass і один full-screen composite. 70 крейдяних штрихів означали приблизно 141 render encoder і 70 full-screen composite.

Нехай кількість видимих крейдяних штрихів дорівнює , а кількість пікселів drawable — . Якщо врахувати лише роботу, що масштабується разом із покриттям пікселів, стара реалізація була близькою до

Кожен крейдяний штрих також мав фіксовані накладні витрати render pass, тож і ця вартість зростала лінійно разом із . 3,571 вхідна точка створювала лише другорядну вартість. Із локальною кількістю штрихів зростав повноекранний обсяг роботи, який запускав кожен штрих.

Вимірювання проводили на 12.9-inch iPad Pro (5th generation, M1) під керуванням iPadOS 18.6.2. Ми порівнювали GPU timestamp з того самого viewport до і після зміни, використовуючи command-buffer timestamp у тій самій Debug device build на цьому конкретному iPad — нижче називатимемо його LucasPad. Наведені діапазони — типові коливання з багатокадрових логів, а не обіцянки frame rate для версії, що постачається. За 70 видимих крейдяних штрихів один кадр зазвичай вимагав 52–60 ms GPU time; у ділянці приблизно зі 120 штрихами GPU time зростав до 77–80 ms.

Якщо оцінювати за площею повноекранного прямокутника scratch і composite pass, теоретичний обсяг роботи за кадр зростав приблизно від 783 million pixels до 1.34 billion pixels. Це число є сумою площ прямокутників і не дорівнює кількості викликів fragment, байтам читання/запису відеопам’яті чи апаратним лічильникам GPU. Fast clear, attachment load/store і pass switching Metal залишаються під контролем GPU та драйвера.

Це також пояснює, чому порожні ділянки залишалися плавними. Visibility culling пропускає штрихи за межами viewport; у порожній ділянці близьке до нуля, тоді як у щільній ділянці продовжує зростати.

Неправильна відповідь за 0.85 ms

У застосунку вже була текстура чорнила на всю сторінку, запечена з розрахунку 2 пікселі на точку. Ми спробували безпосередньо показувати цю текстуру під час письма, панорамування та масштабування, залишаючи лише поточний штрих Apple Pencil живим вектором; після завершення взаємодії ще один кадр перемальовував високороздільний векторний результат.

Цей підхід працював дуже добре. У тій самій щільній ділянці при масштабі 300% GPU time зменшувався до 0.84–0.85 ms і більше не зростав разом із кількістю наявних крейдяних штрихів.

Проблема на реальному пристрої була такою ж очевидною. При масштабі 300% потрібно було приблизно 6 екранних пікселів на точку, але кеш давав лише 2. Щойно Apple Pencil торкався екрана, всі наявні штрихи перетворювалися на м’яке зображення низької роздільності; після підняття Pencil вони поверталися до повної чіткості.

Тестувальник сказав: «Коли я пишу, усе полотно стає розмитим. Воно знову стає чітким, щойно я відпускаю.»

Ми вилучили цю оптимізацію. 0.85 ms був найнижчим виміряним результатом, але це був неприйнятний інструмент крейди. Наявні штрихи є частиною зворотного зв’язку під час письма; їхня чіткість не може змінюватися в момент дотику пера.

Обмеження кожного крейдяного штриха власним прямокутником

Фінальне виправлення зберегло scratch для кожного штриха та композитинг для кожного штриха, зменшивши лише обсяг їхньої піксельної роботи. Кожен штрих уже мав canvas bounding box, виведений з об’єднання всіх радіусів його відбитків. Renderer перетворює цей bounding box у координати drawable поточного viewport і додає 2 пікселі запасу для antialiasing:

Той самий scissor rectangle потім використовується для трьох речей: очищення scratch, малювання штриха та композитингу результату назад на основну поверхню.

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

Та сама логіка використовується і для запікання та часткового повторного відтворення на ink texture 4096², тому відображення на великому масштабі й усталений шар чорнила не створюють двох різних видів поведінки крейди.

Тут легко пропустити дві деталі.

По-перше, loadAction = .clear render pass відбувається на етапі завантаження attachment і не обмежується rasterization scissor. Якщо й далі його використовувати, він усе одно очищатиме всю scratch texture. Виправлений pass використовує .dontCare, а потім малює clear_fragment усередині scissor. Цей прямокутник після цього записується повністю, а composite читає лише той самий прямокутник, тому завантажувати старий вміст attachment не потрібно.

По-друге, після завершення composite кожного крейдяного штриха зовнішній scissor потрібно відновити. Якщо пропустити цей рядок відновлення стану, наступні пера, зображення або виділення й далі обрізатимуться межами попереднього крейдяного штриха, через що зникатимуть штрихи або зображення.

Зернистість крейди й далі семплюється з абсолютних координат canvas, а не з локальних UV усередині прямокутника. Переміщення scissor змінює лише пікселі, які обробляє GPU, але не змінює позицію grain texture, яку читає кожен піксель. Тому сусідні прямокутники не створюють швів текстури, а перетягування полотна не змушує зернистість дрейфувати.

Якщо врахувати лише піксельне навантаження, новий обсяг роботи близький до

де — площа осьово вирівняного bounding box -го крейдяного штриха на поточному екрані. Кількість render encoder не зменшилася, але кожне очищення й композитинг тепер обмежені екранним bounding box штриха.

Чому крейдяні штрихи одного кольору не об’єднали в batch

Більшість крейдяних штрихів на сторінці мають однакові колір і щільність, тож спокусливо намалювати десятки штрихів у scratch одночасно, а потім виконати лише один composite. Це ще зменшило б кількість render pass, але змінило б семантику кольору й зернистості в ділянках перекриття.

Розгляньмо навмисно спрощений випадок: два штрихи в певному пікселі мають однакове значення grain-gate , а покриття тіл дорівнює і . У справжньому shader gate також залежить від pressure depth кожного штриха; цього простого випадку достатньо, щоб показати, що batch загалом не є еквівалентним. Поточний композитинг окремих штрихів дає

тоді як спочатку об’єднані тіла з одним застосуванням gate дають

Різниця дорівнює . Щоразу, коли два штрихи перекриваються, а grain gate не є ні чистим нулем, ні чистою одиницею, результати відрізняються. Безпосередній batch змінив би спосіб, у який крейдяний пил лягає на перетинах.

Для точного batch потрібно довести, що пікселі штрихів взаємно не перетинаються, або виділити окрему ділянку atlas для кожного штриха й виконувати композитинг у початковому порядку. Фінальне тестування на пристрої залишило scissor-підхід, тож цього разу ми не вводили atlas або складність його керування.

Від мільярда пікселів назад до кількох мільйонів

Фінальні вимірювання під час тестування на пристрої:

СценарійДо виправленняТочний scissor
70 видимих крейдяних штрихів, масштаб 300%, письмоGPU 52–60 ms≈ 9–10 ms
≈ 121 видимий крейдяний штрих, масштаб 300%GPU 77–80 ms13.7–15.6 ms
Теоретичний обсяг прямокутників за кадр (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Ми також перевірили межі обрізання на 3,452 штрихах і 202,710 точках зразків із документа на пристрої. При масштабах 0.5×, 1×, 2×, 3×, 5× і 8× було створено 186,408 варіантів viewport; кожен point sprite, який міг створити ненульове покриття, опинився всередині обчисленого scissor. Перевірка охоплювала краї canvas, краї viewport і різні комбінації offset.

Код, який тестували, не перемикається на LOD низької роздільності залежно від стану взаємодії. На низьких масштабах і далі показується повносторінкова ink texture; на високих масштабах видимі штрихи й далі перемальовуються як вектори. По той самий бік порога дотик пера й панорамування не замінюють наявні штрихи іншим рівнем чіткості. Під час векторного перемальовування на великому масштабі очищення scratch і composite кожного крейдяного штриха охоплюють лише його власний екранний bounding box.

GPU time не зафіксував чіткість

Проблеми продуктивності GPU не обов’язково масштабуються разом із найпомітнішою величиною у структурі даних. У цьому випадку 3,571 вхідна точка була очевидним підозрюваним; час кадру визначався повноекранною роботою, яку запускали всі 70 крейдяних штрихів, разом із перемиканням їхніх render pass.

Візуальна семантика також обмежила доступні оптимізації. Scratch для кожного штриха, початковий порядок композитингу та абсолютні координати зернистості canvas не можна було довільно вилучити. Однакові колір і щільність означають лише збіг параметрів — вони не доводять, що результати перекриття можна об’єднати.

Зворотний зв’язок із реального пристрою відхилив версію з найнижчим GPU time. Репліка «усе полотно стає розмитим» виразила продуктове обмеження, якого не показувало саме вимірювання: коли Apple Pencil торкається екрана, користувачі також спостерігають за наявними штрихами.

Тестова версія не додає нового кешу взаємодії й не зменшує чіткість. Векторне перемальовування на великому масштабі просто обмежує роботу кожного крейдяного штриха його власним екранним bounding box. Після повторного завантаження тестової збірки на LucasPad відгук став таким: «Виглядає чудово.»