Lulucat

Výkon nástroje křídy: od průchodů přes celou obrazovku k ořezovým obdélníkům

Gaoge ZhangGaoge Zhang

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.

Oříznutý pohled na Lulucat Notes na iPadu při 255% zvětšení, zobrazující červené a modré psaní křídou s klasickým čínským úryvkem a částmi panelu nástrojů aplikace.

Č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í.

Lulucat Notes na iPadu při 255% zvětšení zobrazuje červené a modré psaní křídou. Text zní „天行健,君子以自强不息;地势坤,君子以厚德载物“ — klasický čínský úryvek. Vpravo nahoře sedí modrý maskot Lulucat. Dolní panel nástrojů zobrazuje počet tahů 155, tlačítka Save a Clear a posuvník zvětšení 255%.

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:

  1. Ukončit hlavní renderovací encoder;
  2. Vymazat scratch texturu;
  3. Vykreslit tento jediný tah křídy do scratch;
  4. Znovu otevřít hlavní renderovací encoder;
  5. 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 a počet pixelů drawable jako . Když vezmeme pouze práci, která škáluje s pokrytím pixelů, byla stará implementace blízká

Každý tah křídy nesl také pevnou režii renderovacího průchodu, takže i tato cena rostla lineárně s . Vstupních 3,571 bodů přispívalo jen sekundární cenou. S počtem místních tahů rostl rozsah práce přes celou obrazovku, který každý tah spouštěl.

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; je v prázdné oblasti blízko nule, zatímco v husté oblasti dál roste.

Š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 je plocha ohraničujícího obdélníku -tého tahu křídy zarovnaného s osami na aktuální obrazovce. Počet renderovacích encoderů se nezmenšil, ale každý clear a composite je nyní omezen obrazovým ohraničujícím obdélníkem daného tahu.

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 a pokrytí těla a . Ve skutečném shaderu závisí brána také na hloubce přítlaku každého tahu; tento jednodušší případ však stačí k prokázání, že batchování obecně není ekvivalentní. Současná kompozice po jednotlivých tazích produkuje

zatímco při prvním sloučení těl a následném použití jediné brány dostaneme

Rozdíl je . Kdykoli se dva tahy překrývají a grain gate není ani čistá nula, ani čistá jednička, výsledky se liší. Přímé batchování by změnilo způsob, jakým křídový prach dopadá na průsečících.

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 opravouPř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 ms13.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.“