Lulucat

Wydajność narzędzia kredy: od pełnoekranowych przebiegów do prostokątów scissor

Gaoge ZhangGaoge Zhang

Narzędzie kredy w Lulucat Notes zwalniało w obszarach gęstego pisma odręcznego. Wąskim gardłem nie było 3,571 próbek wejściowych — było nim 70 pełnoekranowych przebiegów scratch na klatkę. Odrzucony cache niskiej rozdzielczości i prostokąt scissor dla każdego pociągnięcia dopełniają tej historii.

Przycięty widok Lulucat Notes na iPadzie przy powiększeniu 255%, pokazujący czerwone i niebieskie pismo kredą z klasycznym chińskim fragmentem oraz częścią paska narzędzi aplikacji.

Czerwona i niebieska kreda na końcowej wersji urządzenia, 155 pociągnięć przy powiększeniu 255%.

Narzędzie kredy w Lulucat Notes miało konkretny problem z wydajnością: pisanie na pustym obszarze było płynne, ale po wejściu w obszar wypełniony już pociągnięciami kredy końcówka pióra zostawała w tyle. Dalsze pisanie w tym samym miejscu stopniowo spowalniało także przesuwanie płótna.

Do wywołania problemu wystarczała jedna strona zwykłego pisma odręcznego: powiększenie 300%, 70 widocznych pociągnięć kredą w lokalnym obszarze, łącznie 3,571 punktów próbek wejściowych. Puste obszary pozostawały płynne; zwalniał tylko obszar, w którym skupiały się pociągnięcia.

Po poprawce ta sama strona może nadal przyjmować nowe pismo przy powiększeniu 255%, a istniejące pociągnięcia zachowują pełną wyrazistość zarówno przy dotknięciu powierzchni przez pióro, jak i podczas przesuwania płótna.

Lulucat Notes na iPadzie przy powiększeniu 255%, wyświetlający czerwone i niebieskie pismo kredą. Tekst brzmi „天行健,君子以自强不息;地势坤,君子以厚德载物” — to klasyczny chiński fragment. W prawym górnym rogu znajduje się niebieska maskotka Lulucat. Dolny pasek narzędzi pokazuje liczbę pociągnięć 155, przyciski Save i Clear oraz suwak powiększenia 255%.

Końcowy zrzut ekranu urządzenia, łącznie 155 pociągnięć. Przy tym powiększeniu ani dotknięcie powierzchni przez pióro, ani przesuwanie płótna nie zmienia tymczasowo wyrazistości istniejących pociągnięć.

Dlaczego kreda potrzebuje tekstury scratch

Zwykłe pióro może bezpośrednio nakładać każdy okrągły stempel na teksturę atramentu z mieszaniem source-over. Kreda dodaje warstwę sterowania ziarnem: renderer najpierw gromadzi pokrycie korpusu i głębokość dla całego pociągnięcia, następnie za pomocą stałej tekstury ziarna określa, które miejsca otrzymają pył kredowy, a na końcu nakłada wynik na istniejący atrament.

Ta tekstura scratch izoluje pojedyncze pociągnięcie kredą. Izolacja ma znaczenie, ponieważ stemple w obrębie jednego pociągnięcia mocno się nakładają; gdyby każdy stempel był osobno przepuszczany przez ziarno, linia środkowa pociągnięcia gromadziłaby wielokrotnie ten sam kolor, a pory kredy zmieniałyby się wraz z gęstością próbkowania.

Przy dużych powiększeniach Lulucat Notes ponownie rysuje wektorowe pociągnięcia widoczne w bieżącym viewport. Stara implementacja wykonywała dla każdego widocznego pociągnięcia kredą następujące kroki:

  1. Zakończyć główny render encoder;
  2. Wyczyścić teksturę scratch;
  3. Narysować to pojedyncze pociągnięcie kredą w scratch;
  4. Ponownie otworzyć główny render encoder;
  5. Złożyć scratch z drawable za pomocą trójkąta pełnoekranowego.

Semantyka pojedynczego pociągnięcia była poprawna, ale zakres pracy był znacznie większy. Drawable iPada miał rozmiar 2732×2048 — około 5.6 million pixels. Każde pociągnięcie kredą uruchamiało jeden przebieg scratch i jedno pełnoekranowe złożenie. Siedemdziesiąt pociągnięć kredą oznaczało około 141 render encoders i 70 pełnoekranowych złożeń.

Niech liczba widocznych pociągnięć kredą będzie równa , a liczba pikseli drawable — . Uwzględniając tylko pracę skalującą się z pokryciem pikseli, stara implementacja była bliska

Każde pociągnięcie kredą miało także stały narzut render pass, więc ten koszt również rósł liniowo wraz z . 3,571 punktów wejściowych wnosiło tylko koszt drugorzędny. Z liczbą lokalnych pociągnięć skalował się zakres pracy pełnoekranowej uruchamianej przez każde pociągnięcie.

Pomiary wykonaliśmy na 12.9-inch iPad Pro (5th generation, M1) z systemem iPadOS 18.6.2. Porównaliśmy znaczniki czasu GPU dla tego samego viewport przed zmianą i po niej, używając znaczników czasu command buffer w tej samej wersji Debug device build na tym konkretnym iPadzie — nazywanym dalej LucasPad. Podane zakresy to typowe wahania z logów obejmujących wiele klatek, a nie zobowiązania dotyczące liczby klatek w wersji dostarczanej użytkownikom. Przy 70 widocznych pociągnięciach kredą pojedyncza klatka zwykle wymagała 52–60 ms czasu GPU; w obszarze z około 120 pociągnięciami czas GPU wzrastał do 77–80 ms.

Szacując na podstawie powierzchni pełnoekranowych prostokątów przebiegów scratch i composite, teoretyczny zakres pracy na klatkę rósł z około 783 million pixels do 1.34 billion pixels. Ta wartość jest sumą powierzchni prostokątów i nie jest równoważna liczbie wywołań fragmentów, liczbie bajtów odczytu/zapisu pamięci wideo ani licznikom sprzętowym GPU. Fast clear, attachment load/store i przełączanie passów w Metal pozostają pod kontrolą GPU i sterownika.

To wyjaśnia także, dlaczego puste obszary pozostawały płynne. Visibility culling pomija pociągnięcia poza viewport; w pustym obszarze jest bliskie zeru, natomiast w gęstym obszarze stale rośnie.

Błędna odpowiedź przy 0.85 ms

Aplikacja miała już wypieczoną teksturę atramentu całej strony przy dwóch pikselach na point. Próbowaliśmy wyświetlać tę teksturę bezpośrednio podczas pisania, przesuwania i powiększania, pozostawiając tylko bieżące pociągnięcie Apple Pencil jako wektor na żywo; po zakończeniu interakcji jedna dodatkowa klatka ponownie renderowała wektorowy wynik w wysokiej rozdzielczości.

To podejście działało bardzo dobrze. W tym samym gęstym obszarze przy powiększeniu 300% czas GPU spadł do 0.84–0.85 ms i przestał rosnąć wraz z liczbą istniejących pociągnięć kredą.

Problem na prawdziwym urządzeniu był równie wyraźny. Przy powiększeniu 300% potrzebnych było około sześciu pikseli ekranu na point, ale pamięć podręczna zapewniała tylko dwa. W chwili dotknięcia ekranu przez Apple Pencil wszystkie istniejące pociągnięcia zmieniały się w miękki obraz niskiej rozdzielczości; po uniesieniu Pencila wracały do pełnej wyrazistości.

Tester powiedział jedno: „Kiedy piszę, całe płótno robi się rozmyte. Wyostrza się, gdy tylko oderwę pióro”.

Optymalizację usunęliśmy. 0.85 ms było najniższym zmierzonym wynikiem, ale nie było akceptowalnym narzędziem kredy. Istniejące pociągnięcia są częścią informacji zwrotnej podczas pisania; ich wyrazistość nie może zmieniać się przy dotknięciu powierzchni przez pióro.

Ograniczenie każdego pociągnięcia kredą do własnego prostokąta

Końcowa poprawka zachowała scratch i składanie osobno dla każdego pociągnięcia, ograniczając jedynie zakres pracy pikselowej. Każde pociągnięcie miało już prostokąt ograniczający na płótnie wyznaczony przez sumę promieni wszystkich jego stempli. Renderer przekształca ten prostokąt do współrzędnych drawable bieżącego viewport i dodaje dwa piksele marginesu na antialiasing:

Ten sam prostokąt scissor służy następnie do trzech rzeczy: czyszczenia scratch, rysowania pociągnięcia i złożenia wyniku z powrotem na głównej powierzchni.

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

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

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

Ta sama logika jest używana podczas bakingu i częściowego odtwarzania na teksturze atramentu 4096², dzięki czemu wyświetlanie przy dużym powiększeniu i ustalona warstwa atramentu nie tworzą dwóch różnych zachowań kredy.

Łatwo przeoczyć tu dwa szczegóły.

Po pierwsze, loadAction = .clear render pass odbywa się na etapie ładowania attachmentu i nie jest ograniczany przez scissor rasteryzacji. Dalsze używanie tej opcji nadal czyściłoby całą teksturę scratch. Stały pass używa .dontCare, a następnie rysuje clear_fragment wewnątrz scissor. Ten prostokąt zostaje później zapisany w całości, a composite odczytuje tylko ten sam prostokąt, więc nie trzeba ładować starej zawartości attachmentu.

Po drugie, po zakończeniu składania każdego pociągnięcia kredą trzeba przywrócić zewnętrzny scissor. Jeśli pominie się tę linię przywracania stanu, kolejne pióra, obrazy lub zaznaczenia nadal będą przycinane przez granice poprzedniego pociągnięcia kredą i będą wyglądać jak brakujące pociągnięcia lub obrazy.

Ziarno kredy nadal jest próbkowane ze stałych współrzędnych canvas, a nie z lokalnych UV. Przesunięcie scissor zmienia tylko piksele przetwarzane przez GPU; nie zmienia miejsca tekstury ziarna odczytywanego przez dany piksel. Sąsiednie prostokąty nie tworzą więc szwów tekstury, a przeciąganie płótna nie powoduje przesuwania się ziarna.

Uwzględniając tylko obciążenie pikselami, nowy zakres pracy jest bliski

gdzie oznacza pole prostokąta ograniczającego osiowo wyrównanego -tego pociągnięcia kredą na bieżącym ekranie. Liczba encoderów renderowania się nie zmniejszyła, ale każde czyszczenie i składanie jest teraz ograniczone do ekranowego prostokąta ograniczającego pociągnięcia.

Dlaczego pociągnięcia kredą w tym samym kolorze nie zostały połączone w pakiet

Większość pociągnięć kredą na stronie ma ten sam kolor i gęstość, więc kuszące jest narysowanie kilkudziesięciu pociągnięć naraz w scratch i wykonanie tylko jednego składania. Zmniejszyłoby to jeszcze liczbę passów renderowania, ale zmieniłoby semantykę koloru i ziarna w obszarach nakładania.

Rozważmy celowo uproszczony przypadek: dwa pociągnięcia mają w danym pikselu tę samą wartość grain-gate , a ich pokrycia korpusu to i . W rzeczywistym shaderze bramka zależy również od głębokości nacisku każdego pociągnięcia; ten prostszy przypadek wystarcza, by pokazać, że grupowanie pociągnięć na ogół nie daje równoważnego wyniku. Obecne składanie osobno dla każdego pociągnięcia daje

natomiast najpierw połączone korpusy, a potem pojedyncza bramka dają

Różnica wynosi . Gdy dwa pociągnięcia się nakładają, a bramka ziarna nie jest ani czystym zerem, ani czystą jedynką, wyniki są różne. Bezpośrednie łączenie pociągnięć w pakiet zmieniłoby sposób osadzania się pyłu kredowego w miejscach przecięć.

Dokładne łączenie w pakiet wymaga dowodu, że piksele pociągnięć są wzajemnie rozłączne, albo przydzielenia niezależnego regionu atlasu dla każdego pociągnięcia i składania w pierwotnej kolejności. Końcowa akceptacja na urządzeniu zachowała podejście scissor, więc w tej rundzie nie wprowadziliśmy atlasu ani złożoności związanej z jego obsługą.

Od miliarda pikseli z powrotem do kilku milionów

Końcowe pomiary na urządzeniu:

ScenariuszPrzed poprawkąPrecyzyjny scissor
70 widocznych pociągnięć kredą, powiększenie 300%, pisanieGPU 52–60 ms≈ 9–10 ms
≈ 121 widocznych pociągnięć kredą, powiększenie 300%GPU 77–80 ms13.7–15.6 ms
Teoretyczny zakres prostokątów na klatkę (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Zweryfikowaliśmy też granice przycinania, używając 3,452 pociągnięć i 202,710 punktów próbek z dokumentu urządzenia. Przy powiększeniach 0.5×, 1×, 2×, 3×, 5× i 8× wygenerowano 186,408 przypadków viewport; każdy sprite punktu, który mógł wytworzyć niezerowe pokrycie, mieścił się w obliczonym scissor. Test obejmował krawędzie płótna, krawędzie viewport oraz różne kombinacje przesunięcia.

Końcowy kod nie przełącza się na niskorozdzielcze LOD zależne od stanu interakcji. Przy małych powiększeniach nadal wyświetlana jest tekstura atramentu całej strony; przy dużych powiększeniach nadal ponownie rysowane są wektorowo widoczne pociągnięcia. Po tej samej stronie progu dotknięcie powierzchni przez pióro i przesuwanie płótna nie zastępują istniejących pociągnięć innym poziomem wyrazistości. Podczas wektorowego rysowania przy dużym powiększeniu czyszczenie scratch i składanie każdego pociągnięcia kredą obejmują tylko jego własny ekranowy prostokąt ograniczający.

Czas GPU nie rejestrował wyrazistości

Problemy z wydajnością GPU nie muszą skalować się z najbardziej widoczną wielkością w strukturze danych. W tym przypadku 3,571 punktów wejściowych było łatwym podejrzanym; o czasie klatki decydowała praca pełnoekranowa uruchamiana przez każde z 70 pociągnięć kredą wraz z przełączaniem passów renderowania.

Semantyka wizualna również ograniczała dostępne optymalizacje. Scratch osobno dla każdego pociągnięcia, pierwotna kolejność składania i absolutne współrzędne ziarna na płótnie nie mogły zostać usunięte według uznania. Ten sam kolor i ta sama gęstość oznaczają tylko zgodność parametrów — nie dowodzą, że nakładające się wyniki można połączyć.

Informacja zwrotna z prawdziwego urządzenia odrzuciła wersję z najniższym czasem GPU. Uwaga „całe płótno robi się rozmyte” wyznaczyła ograniczenie produktu, którego sam pomiar nie wyrażał: gdy Apple Pencil dotyka powierzchni, użytkownik obserwuje także istniejące pociągnięcia.

Końcowa wersja nie wprowadza nowej warstwy cache interakcji i nie obniża wyrazistości. Wektorowe rysowanie przy dużym powiększeniu ogranicza po prostu pracę dla każdego pociągnięcia kredą do jego własnego ekranowego prostokąta ograniczającego. Po ponownym załadowaniu na LucasPad informacja zwrotna brzmiała: „Wygląda świetnie”.