Performanța instrumentului de cretă: de la treceri pe tot ecranul la dreptunghiuri scissor
Instrumentul de cretă din Lulucat Notes încetinea în zonele cu scris de mână dens. Blocajul nu era reprezentat de cele 3,571 de eșantioane de intrare — ci de 70 de treceri scratch pe tot ecranul pentru fiecare cadru. Un cache respins la rezoluție mică și un dreptunghi scissor pentru fiecare tușă completează povestea.

Cretă roșie și albastră pe versiunea finală a dispozitivului, 155 de tușe la zoom de 255%.
Instrumentul de cretă din Lulucat Notes avea o problemă de performanță specifică: scrisul într-o zonă goală părea fluid, dar, când intrai într-o zonă deja plină de tușe de cretă, vârful instrumentului rămânea în urmă. Continuarea scrisului în aceeași zonă încetinea treptat și deplasarea pânzei.
O singură pagină de scris obișnuit era suficientă pentru a declanșa problema: zoom de 300%, 70 de tușe de cretă vizibile în zona locală, în total 3,571 de puncte de eșantionare a intrării. Zonele goale rămâneau fluide; numai zona în care se concentrau tușele devenea lentă.
După remediere, aceeași pagină poate primi în continuare scris nou la zoom de 255%, iar tușele existente își păstrează claritatea completă atât la atingerea cu stiloul, cât și în timpul deplasării.

Captura finală de pe dispozitiv, cu 155 de tușe în total. La acest nivel de zoom, nici atingerea cu stiloul, nici deplasarea nu schimbă temporar claritatea tușelor existente.
De ce are nevoie creta de o textură scratch
Un stilou obișnuit poate compune direct fiecare ștampilă circulară peste textura de cerneală, folosind amestecarea source-over. Creta adaugă un strat de control al granulației: rendererul acumulează mai întâi acoperirea corpului și profunzimea pentru o tușă întreagă, apoi folosește o textură fixă de granulație pentru a determina pozițiile care primesc praf de cretă și, în final, compune rezultatul peste cerneala existentă.
Această textură scratch izolează o singură tușă de cretă. Izolarea contează deoarece ștampilele din aceeași tușă se suprapun mult; dacă fiecare ștampilă ar fi filtrată separat prin granulație, linia centrală a tușei ar acumula culoare în mod repetat, iar porii cretei s-ar schimba odată cu densitatea eșantionării.
La niveluri mari de zoom, Lulucat Notes redesenează tușele vectoriale vizibile în viewportul curent. Implementarea veche executa acești pași pentru fiecare tușă de cretă vizibilă:
- Închidea encoderul principal de randare;
- Ștergea textura scratch;
- Desena această singură tușă de cretă în scratch;
- Redeschidea encoderul principal de randare;
- Compunea scratch înapoi pe drawable cu un triunghi pe tot ecranul.
Semantica unei singure tușe era corectă, dar volumul de lucru era mult mai mare. Drawable-ul iPadului avea 2732×2048 — aproximativ 5.6 million pixels. Fiecare tușă de cretă declanșa o trecere scratch și o compoziție pe tot ecranul. Șaptezeci de tușe de cretă însemnau aproximativ 141 de encodere de randare și 70 de compoziții pe tot ecranul.
Fie
Fiecare tușă de cretă avea și un cost fix de render pass, astfel că și acest cost creștea liniar cu
Măsurătorile au fost făcute pe un 12.9-inch iPad Pro (5th generation, M1) cu iPadOS 18.6.2. Am comparat timestamp-urile GPU din același viewport înainte și după modificare, folosind timestamp-uri de command buffer în același Debug device build, pe acest iPad anume — numit mai jos LucasPad. Intervalele de mai jos sunt fluctuații obișnuite din loguri cu mai multe cadre, nu promisiuni privind rata de cadre a unei versiuni distribuite. La 70 de tușe de cretă vizibile, un cadru necesita de obicei 52–60 ms de timp GPU; într-o zonă cu aproximativ 120 de tușe, timpul GPU creștea la 77–80 ms.
Estimând după aria dreptunghiurilor pe tot ecranul din trecerile scratch și composite, volumul teoretic de lucru pe cadru creștea de la aproximativ 783 million pixels la 1.34 billion pixels. Această valoare este suma ariilor dreptunghiurilor și nu este echivalentă cu numărul de invocări de fragmente, cu numărul de bytes citiți/scriși în memoria video sau cu contoarele hardware ale GPU-ului. Fast clear, attachment load/store și schimbarea pass-urilor în Metal rămân sub controlul GPU-ului și al driverului.
Asta explică și de ce zonele goale rămâneau fluide. Visibility culling ignoră tușele din afara viewportului; într-o zonă goală,
Un răspuns greșit la 0.85 ms
Aplicația avea deja o textură de cerneală pentru întreaga pagină, baked la doi pixeli per punct. Am încercat să afișăm direct această textură în timpul scrisului, al deplasării și al zoomului, păstrând doar tușa curentă a Apple Pencil ca vector live; după încheierea interacțiunii, un cadru suplimentar reda din nou rezultatul vectorial la rezoluție înaltă.
Abordarea a avut performanțe foarte bune. În aceeași zonă densă, la zoom de 300%, timpul GPU a scăzut la 0.84–0.85 ms și nu a mai crescut odată cu numărul tușelor de cretă existente.
Problema de pe dispozitivul real era la fel de clară. La zoom de 300% erau necesari aproximativ șase pixeli de ecran per punct, dar cache-ul oferea doar doi. În momentul în care Apple Pencil atingea suprafața, toate tușele existente deveneau o imagine moale, la rezoluție mică; când ridicai Pencil-ul, reveneau la claritate completă.
Testerul a spus un singur lucru: „Când scriu, întregul canvas devine neclar. Devine din nou clar imediat ce ridic.”
Optimizarea a fost eliminată. 0.85 ms a fost cel mai mic rezultat măsurat, dar nu era un instrument de cretă acceptabil. Tușele existente fac parte din feedbackul scrisului; claritatea lor nu se poate schimba la atingerea cu stiloul.
Limitarea fiecărei tușe de cretă la propriul dreptunghi
Remedierea finală a păstrat scratch-ul per tușă și compoziția per tușă și a redus doar volumul de lucru pe pixeli. Fiecare tușă avea deja o cutie de încadrare pe canvas, derivată din reuniunea tuturor razelor ștampilelor sale. Rendererul transformă această cutie de încadrare în coordonatele drawable-ului din viewportul curent și adaugă doi pixeli pentru marginea de antialiasing:
Același dreptunghi scissor este folosit pentru trei lucruri: curățarea scratch-ului, desenarea tușei și compunerea rezultatului înapoi pe suprafața principală.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Aceeași logică este folosită și la baking și la partial replay pe textura de cerneală 4096², astfel încât afișarea la zoom mare și stratul de cerneală stabilizat să nu producă două comportamente diferite pentru cretă.
Aici sunt ușor de ratat două detalii.
În primul rând, loadAction = .clear al unui render pass are loc în etapa de încărcare a attachment-ului și nu este limitat de scissor-ul rasterizării. Dacă l-am folosi în continuare, ar șterge totuși întreaga textură scratch. Pass-ul fix folosește .dontCare, apoi desenează un clear_fragment în interiorul scissor-ului. Dreptunghiul este scris ulterior în întregime, iar composite citește doar același dreptunghi, deci conținutul vechi al attachment-ului nu trebuie încărcat.
În al doilea rând, după ce se termină compoziția fiecărei tușe de cretă, scissor-ul exterior trebuie restaurat. Dacă această linie de restaurare a stării este omisă, următoarele stilouri, imagini sau selecții vor continua să fie decupate de limitele tușei de cretă anterioare, părând a avea tușe sau imagini lipsă.
Granulația cretei este încă eșantionată din coordonate absolute ale canvasului, nu din UV-uri locale. Mutarea scissor-ului schimbă doar pixelii procesați de GPU; nu schimbă poziția texturii de granulație citită de fiecare pixel. Prin urmare, dreptunghiurile alăturate nu produc cusături de textură, iar deplasarea canvasului nu face granulația să alunece.
Dacă luăm în calcul doar sarcina de pixeli, noul volum de lucru este apropiat de
unde
De ce nu au fost grupate tușele de cretă de aceeași culoare
Majoritatea tușelor de cretă de pe pagină au aceeași culoare și densitate și este tentant să desenezi zeci de tușe în scratch dintr-o dată și să compui o singură dată. Asta ar reduce și mai mult render pass-urile, dar ar schimba semantica culorii și a granulației în zonele suprapuse.
Să luăm un caz simplificat în mod intenționat: două tușe împart aceeași valoare grain-gate
în timp ce îmbinarea mai întâi a corpurilor și aplicarea unei singure porți produce
Diferența este
Gruparea exactă cere să demonstrăm că pixelii tușelor sunt reciproc disjuncți sau să alocăm o regiune independentă de atlas pentru fiecare tușă și să compunem în ordinea originală. Acceptarea finală pe dispozitiv a păstrat abordarea scissor, așa că în această rundă nu am introdus un atlas și nici complexitatea gestionării lui.
De la un miliard de pixeli înapoi la câteva milioane
Măsurătorile finale pe dispozitiv:
| Scenariu | Înainte de remediere | Scissor precis |
|---|---|---|
| 70 de tușe de cretă vizibile, zoom de 300%, scris | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 de tușe de cretă vizibile, zoom de 300% | GPU 77–80 ms | 13.7–15.6 ms |
| Volum teoretic al dreptunghiurilor pe cadru (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
Am verificat și limitele de clipping folosind 3,452 de tușe și 202,710 puncte de eșantionare dintr-un document de pe dispozitiv. La zoomurile 0.5×, 1×, 2×, 3×, 5× și 8× au fost generate 186,408 cazuri de viewport; fiecare point sprite care putea produce acoperire diferită de zero se afla în scissor-ul calculat. Verificarea a acoperit marginile canvasului, marginile viewportului și diverse combinații de offset.
Codul final nu trece la un LOD de rezoluție mică în funcție de starea interacțiunii. La zoom mic se afișează în continuare textura de cerneală a întregii pagini; la zoom mare se redesenează în continuare ca vectori tușele vizibile. Pe aceeași parte a pragului, atingerea cu stiloul și deplasarea nu înlocuiesc tușele existente cu un alt nivel de claritate. În timpul redesenării vectoriale la zoom mare, curățarea scratch și compoziția fiecărei tușe de cretă acoperă doar propria cutie de încadrare pe ecran.
Timpul GPU nu a capturat claritatea
Problemele de performanță ale GPU-ului nu scalează neapărat cu cea mai vizibilă cantitate din structura de date. În acest caz, cele 3,571 de puncte de intrare erau un suspect ușor; timpul unui cadru era determinat de lucrul pe tot ecranul declanșat de fiecare dintre cele 70 de tușe de cretă, împreună cu schimbarea render pass-urilor.
Semantica vizuală a limitat și optimizările disponibile. Scratch-ul per tușă, ordinea originală de compunere și coordonatele absolute ale granulației pe canvas nu puteau fi eliminate după bunul plac. Aceeași culoare și aceeași densitate înseamnă doar că parametrii se potrivesc — nu demonstrează că rezultatele suprapuse pot fi îmbinate.
Feedbackul de pe dispozitivul real a respins versiunea cu cel mai mic timp GPU. Observația „întregul canvas devine neclar” a exprimat constrângerea de produs pe care măsurarea singură nu o surprinsese: când Apple Pencil atinge suprafața, utilizatorii observă și tușele existente.
Versiunea finală nu introduce un nou strat de cache pentru interacțiune și nu reduce claritatea. Redesenarea vectorială la zoom mare limitează pur și simplu lucrul fiecărei tușe de cretă la propria cutie de încadrare de pe ecran. După ce a fost încărcată din nou pe LucasPad, feedbackul a devenit: „Arată foarte bine.”