Prestazioni dello strumento gesso: dai pass a schermo intero ai rettangoli di scissor
Lo strumento gesso di Lulucat Notes rallentava nelle aree di scrittura a mano dense. Il collo di bottiglia non erano i 3,571 campioni di input, ma 70 scratch pass a schermo intero per frame. Una cache a bassa risoluzione rifiutata e un rettangolo di scissor per tratto raccontano il resto.

Gesso rosso e blu sulla build del dispositivo usata per il risultato finale, 155 tratti con zoom 255%.
Lo strumento gesso di Lulucat Notes aveva un problema di prestazioni specifico: scrivere in un’area vuota risultava fluido, ma entrando in un’area già piena di tratti di gesso la punta della penna iniziava a rimanere indietro. Continuare a scrivere nella stessa area rallentava gradualmente anche il panning della tela.
Bastava una singola pagina di scrittura a mano ordinaria per attivarlo: zoom 300%, 70 tratti di gesso visibili nell’area locale, per un totale di 3,571 punti di campionamento dell’input. Le aree vuote restavano fluide; diventava lenta solo l’area in cui i tratti erano concentrati.
Dopo la correzione, la stessa pagina può continuare a ricevere nuova scrittura con zoom 255%, e i tratti esistenti conservano tutta la loro nitidezza sia durante il pen-down sia durante il panning.

Screenshot del dispositivo usato per il risultato finale, 155 tratti in totale. A questo livello di zoom, né il pen-down né il panning fanno passare temporaneamente i tratti esistenti a una qualità diversa.
Perché il gesso ha bisogno di una texture scratch
Una penna normale può compositare ogni timbro circolare direttamente sulla texture dell’inchiostro con blending source-over. Il gesso aggiunge un livello di grain-gating: il renderer accumula prima la coverage e la profondità del corpo per un intero tratto, poi usa una texture grain fissa per determinare quali posizioni ricevono la polvere di gesso e infine composita il risultato sull’inchiostro esistente.
Questa texture scratch isola un singolo tratto di gesso. L’isolamento è importante perché i timbri all’interno dello stesso tratto si sovrappongono molto; se ogni timbro venisse sottoposto separatamente al grain gate, la linea centrale del tratto accumulerebbe colore ripetuto e i pori del gesso si sposterebbero con la densità del campionamento.
A livelli di zoom elevati, Lulucat Notes ridisegna i tratti vettoriali visibili nel viewport corrente. La vecchia implementazione eseguiva questi passaggi per ogni tratto di gesso visibile:
- Terminare il main render encoder;
- Pulire la texture scratch;
- Disegnare questo singolo tratto di gesso nella scratch;
- Riaprire il main render encoder;
- Compositare la scratch di nuovo sul drawable con un triangolo a schermo intero.
La semantica di un singolo tratto era corretta, ma l’ampiezza del lavoro era molto maggiore. Il drawable dell’iPad era 2732×2048 — circa 5.6 milioni di pixel. Ogni tratto di gesso attivava un pass scratch e un compositing a schermo intero. Settanta tratti di gesso significavano circa 141 render encoder e 70 compositing a schermo intero.
Sia
Ogni tratto di gesso comportava anche un overhead fisso di render pass, quindi anche quel costo cresceva linearmente con
Le misurazioni sono state effettuate su un iPad Pro da 12.9 pollici (5ª generazione, M1) con iPadOS 18.6.2. Abbiamo confrontato i timestamp della GPU nello stesso viewport prima e dopo la modifica, usando timestamp del command buffer nella stessa build del dispositivo Debug su questo particolare iPad — chiamato LucasPad nel seguito. Gli intervalli riportati sotto sono fluttuazioni tipiche di log multi-frame, non impegni sul frame rate di una versione destinata alla distribuzione. Con 70 tratti di gesso visibili, un singolo frame richiedeva in genere 52–60 ms di tempo GPU; in un’area con circa 120 tratti, il tempo GPU saliva a 77–80 ms.
Stimando in base all’area del rettangolo a schermo intero dei pass scratch e composite, l’ampiezza teorica del lavoro per frame cresceva da circa 783 milioni di pixel a 1.34 miliardi di pixel. Questa cifra è la somma delle aree dei rettangoli e non equivale al numero di invocazioni dei fragment, ai byte di lettura/scrittura della memoria video o ai contatori hardware della GPU. Il fast clear di Metal, l’attachment load/store e il cambio di pass restano sotto il controllo della GPU e del driver.
Questo spiega anche perché le aree vuote restavano fluide. Il visibility culling salta i tratti fuori dal viewport;
Una risposta sbagliata a 0.85 ms
L’app aveva già una texture d’inchiostro dell’intera pagina, precalcolata a due pixel per punto. Abbiamo provato a visualizzare direttamente questa texture durante la scrittura, il panning e lo zoom, mantenendo come vettoriale live solo il tratto corrente dell’Apple Pencil; al termine dell’interazione, un frame aggiuntivo avrebbe ridisegnato il risultato vettoriale ad alta risoluzione.
Questo approccio funzionava molto bene. Nella stessa area densa con zoom 300%, il tempo GPU scendeva a 0.84–0.85 ms e non cresceva più con il numero di tratti di gesso esistenti.
Il problema sul dispositivo reale era altrettanto chiaro. Con zoom 300% servivano circa sei pixel dello schermo per punto, ma la cache ne forniva solo due. Nel momento in cui l’Apple Pencil toccava lo schermo, tutti i tratti esistenti diventavano un’immagine morbida a bassa risoluzione; sollevando la Pencil, tornavano di colpo completamente nitidi.
Il tester ha detto una cosa: “Quando scrivo, l’intera tela diventa sfocata. Torna nitida appena lascio andare.”
L’ottimizzazione è stata rimossa. 0.85 ms era il risultato più basso misurato, ma non era uno strumento gesso accettabile. I tratti esistenti fanno parte del feedback di scrittura; la loro nitidezza non può cambiare al pen-down.
Limitare ogni tratto di gesso al proprio rettangolo
La correzione finale ha mantenuto scratch e compositing per tratto, riducendo soltanto l’ampiezza del lavoro sui pixel. Ogni tratto aveva già un bounding box del canvas ricavato dall’unione di tutti i raggi dei suoi timbri. Il renderer trasforma questo bounding box nelle coordinate drawable del viewport corrente e lo espande di due pixel per il margine di antialiasing:
Lo stesso rettangolo di scissor viene quindi usato per tre cose: pulire la scratch, disegnare il tratto e ricompositare il risultato sulla superficie principale.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
La stessa logica viene usata anche per il baking e il partial replay sulla texture d’inchiostro 4096², così la visualizzazione ad alto zoom e il livello d’inchiostro consolidato non producono due comportamenti diversi del gesso.
Qui è facile lasciarsi sfuggire due dettagli.
Primo, loadAction = .clear di un render pass avviene durante la fase di attachment load e non è vincolato dal rasterization scissor. Continuare a usarlo pulirebbe comunque l’intera texture scratch. Il pass corretto usa .dontCare, poi disegna un clear_fragment all’interno dello scissor. Questo rettangolo viene successivamente scritto per intero, e il composite legge solo lo stesso rettangolo, quindi non è necessario caricare i contenuti precedenti dell’attachment.
Secondo, dopo che il composite di ogni tratto di gesso è terminato, lo scissor esterno deve essere ripristinato. Se questa riga di ripristino dello stato viene omessa, penne, immagini o selezioni successive continueranno a essere ritagliate dai limiti del tratto di gesso precedente, apparendo come tratti o immagini mancanti.
Il grain del gesso continua a campionare dalle coordinate assolute del canvas invece che da UV locali all’interno del rettangolo. Spostare lo scissor cambia solo quali pixel la GPU elabora; non cambia quale posizione della texture grain legge ogni pixel. I rettangoli adiacenti non producono quindi discontinuità nella texture e trascinare la tela non fa derivare il grain.
Considerando solo il carico di lavoro sui pixel, la nuova ampiezza del lavoro è vicina a
dove
Perché i tratti di gesso dello stesso colore non sono stati raggruppati in batch
La maggior parte dei tratti di gesso sulla pagina condivide lo stesso colore e la stessa densità, ed è allettante disegnare decine di tratti nella scratch tutti insieme e compositare una sola volta. Questo ridurrebbe ulteriormente i render pass, ma cambierebbe la semantica del colore e del grain nelle regioni sovrapposte.
Consideriamo un caso volutamente semplificato: due tratti condividono lo stesso valore di grain-gate
mentre unire prima i body e applicare poi un solo gate produce
La differenza è
Un batching esatto richiede di dimostrare che i pixel dei tratti siano mutuamente disgiunti, oppure di allocare una regione atlas indipendente per ogni tratto e compositare nell’ordine originale. L’accettazione finale sul dispositivo ha mantenuto l’approccio con scissor, quindi questo giro non ha introdotto un atlas né la complessità della sua gestione.
Da un miliardo di pixel a pochi milioni
Le misurazioni finali sul dispositivo:
| Scenario | Prima della correzione | Scissor preciso |
|---|---|---|
| 70 tratti di gesso visibili, zoom 300%, scrittura | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 tratti di gesso visibili, zoom 300% | GPU 77–80 ms | 13.7–15.6 ms |
| Ampiezza teorica dei rettangoli per frame (scratch + composite) | 783 M–1.34 B pixel | ≈ 1.7 M–3 M pixel |
Abbiamo verificato anche i limiti di clipping usando 3,452 tratti e 202,710 punti campione provenienti da un documento sul dispositivo. Con zoom 0.5×, 1×, 2×, 3×, 5× e 8× sono stati generati 186,408 casi di viewport; ogni point sprite che poteva produrre coverage non-zero rientrava nello scissor calcolato. Il controllo copriva i bordi del canvas, i bordi del viewport e varie combinazioni di offset.
Il codice finale non passa a un LOD a bassa risoluzione in base allo stato dell’interazione. I livelli di zoom bassi continuano a mostrare la texture d’inchiostro dell’intera pagina; i livelli di zoom alti continuano a ridisegnare come vettori i tratti visibili. Sullo stesso lato della soglia, pen-down e panning non sostituiscono i tratti esistenti con un diverso livello di nitidezza. Durante il ridisegno vettoriale ad alto zoom, il clear scratch e il composite di ogni tratto di gesso coprono solo il suo bounding box sullo schermo.
Il tempo GPU non catturava la nitidezza
I problemi di prestazioni della GPU non scalano necessariamente con la quantità più visibile nella struttura dati. In questo caso, i 3,571 punti di input erano un sospetto facile; ciò che determinava il tempo per frame era il lavoro a schermo intero attivato da ciascuno dei 70 tratti di gesso, insieme ai cambi di render pass.
Anche la semantica visiva limitava le ottimizzazioni disponibili. Scratch per tratto, ordine originale del compositing e coordinate assolute del grain sul canvas non potevano essere rimossi a piacere. Stesso colore e stessa densità significano solo che i parametri corrispondono — non dimostrano che i risultati sovrapposti possano essere uniti.
Il feedback del dispositivo reale ha respinto la versione con il tempo GPU più basso. La frase “l’intera tela diventa sfocata” ha fornito il vincolo di prodotto che la sola misurazione non aveva espresso: quando l’Apple Pencil tocca lo schermo, gli utenti stanno anche osservando i tratti esistenti.
La versione finale non introduce un nuovo livello di cache dell’interazione e non riduce la nitidezza. Il ridisegno vettoriale ad alto zoom limita semplicemente il lavoro di ogni tratto di gesso al proprio bounding box sullo schermo. Dopo che è stata caricata di nuovo su LucasPad, la risposta è diventata: “Sembra ottimo.”