Výkon nástroje křídy: od průchodů přes celou obrazovku k ořezovým obdélníkům
Nástroj křídy v Lulucat Notes se v hustě popsaných oblastech zpomaloval. Úzkým místem nebylo 3,571 vstupních vzorků, ale 70 průchodů scratch přes celou obrazovku v každém snímku. Zbytek příběhu doplňuje odmítnutá cache s nízkým rozlišením a ořezový obdélník pro každý tah.

Červená a modrá křída v testovací sestavě pro zařízení, 155 tahů při 255% zvětšení.
Nástroj křídy v Lulucat Notes měl specifický problém s výkonem: psaní v prázdné oblasti působilo plynule, ale po přesunu do oblasti už vyplněné tahy křídy začal hrot pera zaostávat. Další psaní ve stejné oblasti postupně zpomalovalo i posouvání plátna.
Ke spuštění problému stačila jediná stránka běžného rukopisu: 300% zvětšení, 70 viditelných tahů křídy v místní oblasti a celkem 3,571 bodů vstupních vzorků. Prázdné oblasti zůstávaly plynulé; zpomalila se jen oblast, v níž byly tahy soustředěné.
Po opravě může stejná stránka při 255% zvětšení dál přijímat nové psaní a existující tahy si zachovávají plnou ostrost při psaní i posouvání.

Snímek z testu na zařízení, celkem 155 tahů. Při této úrovni zvětšení se ostrost existujících tahů dočasně nemění ani při psaní, ani při posouvání.
Proč křída potřebuje scratch texturu
Běžné pero může skládat každý kruhový otisk přímo do inkoustové textury v režimu source-over. Křída přidává vrstvu řízení zrnitosti: renderer nejprve pro celý tah akumuluje pokrytí a hloubku těla, potom pomocí pevné textury zrna určí, které pozice dostanou prach křídy, a nakonec výsledek složí na existující inkoust.
Tato textura scratch izoluje jediný tah křídy. Izolace je důležitá, protože otisky uvnitř jednoho tahu se silně překrývají; kdyby každý otisk procházel řízením zrnitosti samostatně, středová linie tahu by opakovaně nabírala barvu a póry křídy by se posouvaly s hustotou vzorkování.
Při vysokých úrovních zvětšení Lulucat Notes překresluje vektorové tahy viditelné v aktuálním viewportu. Stará implementace prováděla pro každý viditelný tah křídy tyto kroky:
- Ukončit hlavní renderovací encoder;
- Vymazat scratch texturu;
- Vykreslit tento jediný tah křídy do scratch;
- Znovu otevřít hlavní renderovací encoder;
- Složit scratch zpět do drawable pomocí trojúhelníku přes celou obrazovku.
Význam jediného tahu byl správný, ale rozsah práce byl mnohem větší. Drawable iPadu měl rozměry 2732×2048 — přibližně 5.6 milionu pixelů. Každý tah křídy spustil jeden scratch pass a jednu kompozici přes celou obrazovku. 70 tahů křídy znamenalo přibližně 141 renderovacích encoderů a 70 kompozic přes celou obrazovku.
Označme počet viditelných tahů křídy jako
Každý tah křídy nesl také pevnou režii renderovacího průchodu, takže i tato cena rostla lineárně s
Měření probíhalo na 12.9-inch iPad Pro (5. generace, M1) s iPadOS 18.6.2. Porovnali jsme GPU timestampy ze stejného viewportu před změnou a po ní pomocí timestampů command bufferu ve stejném Debug device buildu na tomto konkrétním iPadu — níže mu říkáme LucasPad. Uvedené rozsahy jsou typické výkyvy z logů více snímků, nikoli příslib snímkové frekvence pro sestavení určené k distribuci. Při 70 viditelných tazích křídy potřeboval jeden snímek obvykle 52–60 ms GPU času; v oblasti s přibližně 120 tahy vzrostl GPU čas na 77–80 ms.
Při odhadu podle plochy obdélníku přes celou obrazovku u scratch a composite passů vzrostl teoretický rozsah práce na snímek přibližně ze 783 milionů pixelů na 1.34 miliardy pixelů. Toto číslo je součtem ploch obdélníků a neodpovídá počtu vyvolání fragmentů, počtu bajtů čtení/zápisu videopaměti ani hardwarovým čítačům GPU. Fast clear v Metal, načítání/ukládání attachmentů a přepínání passů zůstávají pod kontrolou GPU a ovladače.
To také vysvětluje, proč prázdné oblasti zůstávaly plynulé. Culling viditelnosti vynechává tahy mimo viewport;
Špatná odpověď za 0.85 ms
Aplikace už měla předpečenou inkoustovou texturu celé stránky při dvou pixelech na bod. Zkusili jsme tuto texturu zobrazovat přímo během psaní, posouvání a zoomování a jako živý vektor ponechat pouze aktuální tah Apple Pencil; po skončení interakce by jedno další snímkové období znovu vykreslilo vektorový výsledek ve vysokém rozlišení.
Tento přístup fungoval velmi dobře. Ve stejné husté oblasti při 300% zvětšení klesl GPU čas na 0.84–0.85 ms a s počtem existujících tahů křídy už nerostl.
Na skutečném zařízení byl problém stejně zřejmý. Při 300% zvětšení bylo potřeba přibližně šest obrazových pixelů na bod, ale cache poskytovala jen dva. Jakmile se Apple Pencil dotkl povrchu, všechny existující tahy se změnily v měkký obraz s nízkým rozlišením; po zvednutí Apple Pencil okamžitě přeskočily zpět do plné ostrosti.
Tester řekl jednu věc: „Když píšu, celé plátno se rozmaže. Jakmile pero zvednu, zase se vyčistí.“
Optimalizaci jsme odstranili. Výsledek 0.85 ms byl nejnižší naměřený, ale nepředstavoval přijatelný nástroj křídy. Existující tahy jsou součástí zpětné vazby při psaní; jejich ostrost se při psaní nesmí změnit.
Omezení každého tahu křídy na vlastní obdélník
Výsledná oprava zachovala scratch pro každý tah i kompozici každého tahu a zmenšila pouze rozsah jejich pixelové práce. Každý tah už měl ohraničující obdélník plátna odvozený ze sjednocení poloměrů všech svých otisků. Renderer transformuje tento obdélník do souřadnic drawable aktuálního viewportu a rozšíří ho o dva pixely kvůli antialiasingovému okraji:
Stejný ořezový obdélník (scissor) se pak použije ke třem věcem: vyčištění scratch, vykreslení tahu a složení výsledku zpět na hlavní povrch.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Stejná logika se používá také při předpečení a částečném přehrání na inkoustové textuře 4096², takže zobrazení s vysokým zoomem a ustálená inkoustová vrstva nevytvářejí dvě různá chování křídy.
Zde lze snadno přehlédnout dva detaily.
Za prvé, loadAction = .clear renderovacího passu probíhá během fáze načítání attachmentu a rasterizační ořez ho neomezuje. Jeho další používání by stále vyčistilo celou scratch texturu. Opravený pass používá .dontCare a potom uvnitř ořezu vykreslí clear_fragment. Tento obdélník se následně celý zapíše a kompozice čte jen tentýž obdélník, takže není třeba načítat starý obsah attachmentu.
Za druhé, po dokončení kompozice každého tahu křídy je nutné obnovit vnější ořez. Pokud tento řádek obnovující stav vynecháme, budou následující pera, obrázky nebo výběry dál ořezány hranicemi předchozího tahu křídy a budou vypadat jako chybějící tahy nebo obrázky.
Zrno křídy se stále vzorkuje z absolutních souřadnic plátna, nikoli z lokálních UV v obdélníku. Posunutí ořezu mění jen pixely, které GPU zpracovává; nemění místo v textuře zrna, ze kterého jednotlivé pixely čtou. Sousední obdélníky proto nevytvářejí švy textury a při tažení plátna se zrno neposouvá.
Když vezmeme pouze pixelovou zátěž, nový rozsah práce se blíží
kde
Proč jsme tahy křídy stejné barvy nesloučili do batchu
Většina tahů křídy na stránce má stejnou barvu i hustotu a svádí to vykreslit desítky tahů do scratch najednou a kompozici provést jen jednou. To by počet renderovacích passů ještě snížilo, ale změnilo by to význam barvy a zrna v překrývajících se oblastech.
Uvažujme záměrně zjednodušený případ: dva tahy mají v určitém pixelu stejnou hodnotu grain gate
zatímco při prvním sloučení těl a následném použití jediné brány dostaneme
Rozdíl je
Přesné batchování vyžaduje dokázat, že pixely tahů jsou vzájemně disjunktní, nebo pro každý tah vyčlenit nezávislou oblast atlasu a skládat ji v původním pořadí. Ověření na zařízení zachovalo přístup s ořezovým obdélníkem, takže tato iterace nepřidala atlas ani složitost jeho správy.
Z miliardy pixelů zpět na několik milionů
Měření na zařízení:
| Scénář | Před opravou | Přesný ořez |
|---|---|---|
| 70 viditelných tahů křídy, 300% zvětšení, psaní | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 viditelných tahů křídy, 300% zvětšení | GPU 77–80 ms | 13.7–15.6 ms |
| Teoretický rozsah obdélníků na snímek (scratch + composite) | 783 M–1.34 B pixelů | ≈ 1.7 M–3 M pixelů |
Ověřili jsme také hranice ořezu pomocí 3,452 tahů a 202,710 vzorkovacích bodů z dokumentu na zařízení. Při zoomech 0.5×, 1×, 2×, 3×, 5× a 8× vzniklo 186,408 případů viewportu; každý point sprite, který mohl vytvořit nenulové pokrytí, ležel uvnitř vypočteného ořezu. Kontrola zahrnovala okraje plátna, okraje viewportu a různé kombinace offsetů.
Výsledná implementace nepřepíná na LOD s nízkým rozlišením podle stavu interakce. Při nízkém zoomu se stále zobrazuje inkoustová textura celé stránky; při vysokém zoomu se stále znovu vykreslují viditelné tahy jako vektory. Na stejné straně prahu psaní ani posouvání nenahrazují existující tahy jinou úrovní ostrosti. Při vektorovém překreslení ve vysokém zoomu pokrývají clear a composite každého tahu křídy jen jeho vlastní obrazový ohraničující obdélník.
GPU čas nezachytil ostrost
Problémy s výkonem GPU se nemusí škálovat s nejviditelnější veličinou v datové struktuře. V tomto případě byly 3,571 vstupních bodů snadným podezřelým; čas snímku však určovala práce přes celou obrazovku spuštěná každým ze 70 tahů křídy spolu s přepínáním renderovacích passů.
Vizuální významy také omezily dostupné optimalizace. Scratch pro každý tah, původní pořadí kompozice a absolutní souřadnice zrna plátna nešlo libovolně odstranit. Stejná barva a stejná hustota znamenají jen shodné parametry — nedokazují, že lze překrývající se výsledky sloučit.
Zpětná vazba ze skutečného zařízení odmítla verzi s nejnižším GPU časem. Poznámka „celé plátno se rozmaže“ poskytla produktové omezení, které samotné měření nevyjádřilo: když se Apple Pencil dotkne povrchu, uživatelé zároveň sledují existující tahy.
Výsledná implementace nepřidává novou interakční cache vrstvu a nesnižuje ostrost. Vektorové překreslení ve vysokém zoomu jednoduše omezuje práci každého tahu křídy na jeho vlastní obrazový ohraničující obdélník. Po opětovném načtení na LucasPad zněla zpětná vazba: „Vypadá to skvěle.“