Wydajność narzędzia kredy: od pełnoekranowych przebiegów do prostokątów scissor
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.

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.

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:
- Zakończyć główny render encoder;
- Wyczyścić teksturę scratch;
- Narysować to pojedyncze pociągnięcie kredą w scratch;
- Ponownie otworzyć główny render encoder;
- 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
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
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
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
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
natomiast najpierw połączone korpusy, a potem pojedyncza bramka dają
Różnica wynosi
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:
| Scenariusz | Przed poprawką | Precyzyjny scissor |
|---|---|---|
| 70 widocznych pociągnięć kredą, powiększenie 300%, pisanie | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 widocznych pociągnięć kredą, powiększenie 300% | GPU 77–80 ms | 13.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”.