Prestaties van het krijtgereedschap: van fullscreen-passes naar scissorrechthoeken
Het krijtgereedschap van Lulucat Notes werd traag in dichtbeschreven handschriftgebieden. Het knelpunt waren niet de 3,571 invoermonsters, maar 70 fullscreen-scratch-passes per frame. Een afgewezen cache met lage resolutie en een eigen scissorrechthoek per streek vertellen de rest van het verhaal.

Rood en blauw krijt op de apparaatbuild voor de eindcontrole, 155 streken bij 255% zoom.
Het krijtgereedschap in Lulucat Notes had een specifiek prestatieprobleem: schrijven in een leeg gebied voelde vloeiend, maar zodra de pen in een gebied kwam dat al vol stond met krijtstreken, begon de penpunt achter te lopen. Doorgaan in datzelfde gebied maakte ook het pannen van het canvas geleidelijk trager.
Eén gewone handgeschreven pagina was genoeg om het probleem te activeren: 300% zoom, 70 zichtbare krijtstreken in het lokale gebied, in totaal 3,571 invoermonsterpunten. Lege gebieden bleven vloeiend; alleen het gebied waar de streken geconcentreerd waren werd traag.
Na de oplossing kan dezelfde pagina bij 255% zoom nieuwe tekst blijven ontvangen, en behouden bestaande streken hun volledige scherpte tijdens zowel pen-down als pannen.

Uiteindelijke devicescreenshot voor de eindcontrole, 155 streken in totaal. Op dit zoomniveau schakelen bestaande streken tijdens pen-down of pannen niet tijdelijk naar een andere scherpte.
Waarom krijt een scratch-textuur nodig heeft
Een gewone pen kan elke ronde stempel rechtstreeks op de inkttextuur compositen met source-over-blending. Krijt voegt een grain-gatinglaag toe: de renderer verzamelt eerst de body coverage en depth voor een hele streek, gebruikt daarna een vaste grain-textuur om te bepalen welke posities krijtstof krijgen, en compositet ten slotte het resultaat op de bestaande inkt.
Deze scratch-textuur isoleert één krijtstreek. Isolatie is belangrijk omdat stempels binnen dezelfde streek sterk overlappen; als elke stempel afzonderlijk grain-gated zou worden, zou de middellijn van de streek herhaalde kleur opstapelen en zouden de krijtporiën verschuiven met de samplingdichtheid.
Bij hoge zoomniveaus tekent Lulucat Notes de zichtbare vectorstreken in de huidige viewport opnieuw. De oude implementatie voerde voor elke zichtbare krijtstreek deze stappen uit:
- De main render encoder beëindigen;
- De scratch-textuur wissen;
- Deze ene krijtstreek in de scratch tekenen;
- De main render encoder opnieuw openen;
- De scratch met een fullscreen-driehoek terug compositen naar de drawable.
De semantiek van één streek was correct, maar de werkomvang was veel groter. De drawable van de iPad was 2732×2048 — ongeveer 5.6 miljoen pixels. Elke krijtstreek activeerde één scratch-pass en één fullscreen-composite. Zeventig krijtstreken betekenden ongeveer 141 render encoders en 70 fullscreen-composites.
Laat het aantal zichtbare krijtstreken
Elke krijtstreek bracht ook vaste overhead voor de render-pass mee, dus ook die kosten schalen lineair met
De metingen werden uitgevoerd op een 12.9-inch iPad Pro (5e generatie, M1) met iPadOS 18.6.2. We vergeleken GPU-timestamps van dezelfde viewport voor en na de wijziging, met command-buffer-timestamps in dezelfde Debug-device-build op deze specifieke iPad — hieronder LucasPad genoemd. De bereiken hieronder zijn typische schommelingen uit logs over meerdere frames, geen toezeggingen over de framerate van een toekomstige versie. Bij 70 zichtbare krijtstreken had één frame doorgaans 52–60 ms GPU-tijd nodig; in een gebied met ongeveer 120 streken liep de GPU-tijd op tot 77–80 ms.
Op basis van de fullscreen-rechthoekoppervlakte van de scratch- en composite-passes groeide de theoretische werkomvang per frame van ongeveer 783 miljoen pixels naar 1.34 miljard pixels. Dit getal is de som van rechthoekoppervlakken en is niet gelijk aan het aantal fragmentaanroepen, het aantal bytes dat in videogeheugen wordt gelezen/geschreven of GPU-hardwaretellers. Metal-fast-clear, attachment load/store en het wisselen van passes blijven onder controle van GPU en driver.
Dit verklaart ook waarom lege gebieden vloeiend bleven. Visibility culling slaat streken buiten de viewport over;
Een verkeerd antwoord bij 0.85 ms
De app had al een inkttextuur voor de hele pagina, gebakken op twee pixels per punt. We probeerden deze textuur rechtstreeks te tonen tijdens schrijven, pannen en zoomen, waarbij alleen de huidige Apple Pencil-streek als live vector behouden bleef; na afloop van de interactie zou één extra frame het vectorresultaat met hoge resolutie opnieuw renderen.
Deze aanpak presteerde zeer goed. In hetzelfde dichte gebied bij 300% zoom daalde de GPU-tijd naar 0.84–0.85 ms en groeide die niet langer met het aantal bestaande krijtstreken.
Het probleem op het echte apparaat was even duidelijk. Bij 300% zoom waren ongeveer zes schermpixels per punt nodig, maar de cache leverde er slechts twee. Zodra de Apple Pencil het scherm raakte, veranderden alle bestaande streken in een zacht beeld met lage resolutie; nadat de Pencil werd opgetild, sprongen ze terug naar volledige scherpte.
De tester zei: “Als ik schrijf, wordt het hele canvas wazig. Zodra ik loslaat, wordt het weer scherp.”
De optimalisatie werd verwijderd. 0.85 ms was het laagst gemeten resultaat, maar het was geen aanvaardbaar krijtgereedschap. Bestaande streken maken deel uit van de schrijf-feedback; hun scherpte mag niet veranderen bij pen-down.
Elke krijtstreek beperken tot zijn eigen rechthoek
De uiteindelijke oplossing behield scratch per streek en compositing per streek en verkleinde alleen de hoeveelheid pixelwerk. Elke streek had al een canvas-bounding box die was afgeleid van de unie van alle stralen van zijn stempels. De renderer transformeert deze bounding box naar de drawable-coördinaten van de huidige viewport en voegt twee pixels padding toe als antialiasingmarge.
Dezelfde scissorrechthoek wordt vervolgens voor drie dingen gebruikt: de scratch wissen, de streek tekenen en het resultaat terug compositen op het hoofdoppervlak.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Dezelfde logica wordt ook gebruikt voor baking en partial replay op de 4096²-inkttextuur, zodat de weergave bij hoge zoom en de neergestreken inktlaag niet twee verschillende krijtgedragingen opleveren.
Twee details worden hier gemakkelijk gemist.
Ten eerste vindt loadAction = .clear van een render-pass plaats tijdens de attachment-loadfase en wordt die niet beperkt door de rasterization scissor. Als we dit zouden blijven gebruiken, zou de volledige scratch-textuur nog steeds worden gewist. De vaste pass gebruikt .dontCare en tekent daarna een clear_fragment binnen de scissor. Deze rechthoek wordt vervolgens volledig beschreven en de composite leest alleen dezelfde rechthoek, zodat oude attachment-inhoud niet hoeft te worden geladen.
Ten tweede moet de buitenste scissor worden hersteld nadat de composite van elke krijtstreek klaar is. Als deze regel voor state-herstel ontbreekt, blijven volgende pennen, afbeeldingen of selecties worden geclipt door de grenzen van de vorige krijtstreek, waardoor ze eruitzien als ontbrekende streken of afbeeldingen.
De grain van het krijt wordt nog steeds uit absolute canvascoördinaten gesampled, niet uit lokale UV’s binnen de rechthoek. De scissor verplaatsen verandert alleen welke pixels de GPU verwerkt; niet welke locatie in de grain-textuur elk pixel leest. Aangrenzende rechthoeken veroorzaken daarom geen textuurnaden, en door het canvas te slepen gaat de grain niet drijven.
Als we alleen de pixelbelasting bekijken, ligt de nieuwe werkomvang dicht bij
waarbij
Waarom krijtstreken met dezelfde kleur niet werden gebatcht
De meeste krijtstreken op de pagina hebben dezelfde kleur en dichtheid, en het is verleidelijk om tientallen streken tegelijk in de scratch te tekenen en slechts één keer te compositen. Dat zou het aantal render-passes verder verminderen, maar het verandert de semantiek van kleur en grain in overlappende gebieden.
Neem een bewust vereenvoudigd geval: twee streken delen in een bepaalde pixel dezelfde grain-gatewaarde
terwijl het eerst samenvoegen van de bodies en het daarna toepassen van één gate oplevert:
Het verschil is
Exact batchen vereist dat we bewijzen dat de pixels van de streken elkaar niet overlappen, of dat we voor elke streek een onafhankelijke atlasregio toewijzen en in de oorspronkelijke volgorde compositen. De uiteindelijke acceptatie op het apparaat behield de scissor-aanpak, dus deze ronde introduceerde geen atlas en ook niet de complexiteit van het beheer ervan.
Van een miljard pixels terug naar enkele miljoenen
De uiteindelijke metingen op het apparaat:
| Scenario | Voor de oplossing | Precieze scissor |
|---|---|---|
| 70 zichtbare krijtstreken, 300% zoom, schrijven | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 zichtbare krijtstreken, 300% zoom | GPU 77–80 ms | 13.7–15.6 ms |
| Theoretische rechthoekomvang per frame (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
We controleerden de clippinggrenzen ook met 3,452 streken en 202,710 monsterpunten uit een document op het apparaat. Bij zoom 0.5×, 1×, 2×, 3×, 5× en 8× werden 186,408 viewportgevallen gegenereerd; elk point sprite dat non-zero coverage kon produceren viel binnen de berekende scissor. Deze controle omvatte canvasranden, viewportranden en verschillende combinaties van offsets.
De uiteindelijke code schakelt niet over naar een LOD met lage resolutie op basis van de interactiestatus. Lage zoomniveaus tonen nog steeds de inkttextuur van de hele pagina; hoge zoomniveaus tekenen zichtbare streken nog steeds opnieuw als vectoren. Aan dezelfde kant van de drempel vervangen pen-down en pannen bestaande streken niet door een ander niveau van scherpte. Tijdens het opnieuw tekenen van vectoren bij hoge zoom bedekken de scratch clear en composite van elke krijtstreek alleen zijn eigen bounding box op het scherm.
GPU-tijd legde scherpte niet vast
GPU-prestatieproblemen schalen niet noodzakelijk met de meest zichtbare hoeveelheid in de datastructuur. In dit geval waren 3,571 invoerpunten een voor de hand liggende verdachte; wat de frametijd bepaalde was het fullscreen-werk dat door elk van de 70 krijtstreken werd veroorzaakt, samen met het wisselen van render-passes.
Visuele semantiek beperkte ook de beschikbare optimalisaties. Scratch per streek, de oorspronkelijke compositingvolgorde en absolute canvas-graincoördinaten konden niet naar believen worden verwijderd. Dezelfde kleur en dezelfde dichtheid betekenen alleen dat de parameters overeenkomen — ze bewijzen niet dat overlappende resultaten kunnen worden samengevoegd.
Feedback van het echte apparaat wees de versie met de laagste GPU-tijd af. De opmerking “het hele canvas wordt wazig” leverde de productbeperking die de meting alleen niet had uitgedrukt: wanneer de Apple Pencil het scherm raakt, kijken gebruikers ook naar de bestaande streken.
De definitieve versie voegt geen nieuwe interactie-cachelaag toe en vermindert de scherpte niet. Het opnieuw tekenen van vectoren bij hoge zoom beperkt het werk van elke krijtstreek alleen tot zijn eigen bounding box op het scherm. Nadat de versie opnieuw op LucasPad was geladen, luidde de feedback: “Ziet er geweldig uit.”