Prestanda för kritverktyget: från helskärmspass till scissor-rektanglar
Kritverktyget i Lulucat Notes blev långsamt i områden med tät handskrift. Flaskhalsen var inte de 3,571 indata-sampelen — det var 70 scratch-pass över hela skärmen per bildruta. En avvisad cache med låg upplösning och en scissor-rektangel per streck berättar resten av historien.

Röd och blå krita på den slutliga enhetsversionen, 155 streck vid 255% zoom.
Kritverktyget i Lulucat Notes hade ett specifikt prestandaproblem: att skriva på en tom yta kändes mjukt, men när man gick in i ett område som redan var fyllt med kritstreck hamnade pennspetsen efter. Om man fortsatte skriva i samma område blev även panorering av arbetsytan gradvis långsammare.
En enda sida med vanlig handskrift räckte för att utlösa problemet: 300% zoom, 70 synliga kritstreck i det lokala området och totalt 3,571 indata-samplingspunkter. Tomma områden förblev mjuka; bara området där strecken var koncentrerade blev långsamt.
Efter korrigeringen kan samma sida fortsätta ta emot ny skrift vid 255% zoom, och befintliga streck behåller full tydlighet både när pennan sätts mot ytan och vid panorering.

Den slutliga enhetsskärmbilden, totalt 155 streck. På den här zoomnivån ändrar varken pennans kontakt eller panorering tillfälligt tydligheten hos befintliga streck.
Varför krita behöver en scratch-textur
En vanlig penna kan kompositera varje cirkulär stämpel direkt på bläcktexturen med source-over-blandning. Krita lägger till ett lager för kornstyrning: renderaren samlar först kroppstäckning och djup för ett helt streck, använder sedan en fast korntextur för att avgöra vilka positioner som får kritdamm och komponerar till sist resultatet med det befintliga bläcket.
Den här scratch-texturen isolerar ett enda kritstreck. Isoleringen spelar roll eftersom stämplarna inom samma streck överlappar kraftigt; om varje stämpel kornstyrdes separat skulle streckets mittlinje samla på sig färg upprepade gånger och kritans porer förändras med samplingsdensiteten.
Vid höga zoomnivåer ritar Lulucat Notes om de vektorstreck som syns i den aktuella viewporten. Den gamla implementationen gjorde följande för varje synligt kritstreck:
- Avsluta den huvudsakliga render encodern;
- Rensa scratch-texturen;
- Rita det här enda kritstrecket i scratch;
- Öppna den huvudsakliga render encodern igen;
- Kompositera scratch tillbaka till drawable med en helskärmstriangel.
Semantiken för ett streck var korrekt, men arbetsomfattningen var mycket större. iPadens drawable var 2732×2048 — ungefär 5.6 million pixels. Varje kritstreck utlöste ett scratch-pass och en helskärmskomposition. Sjuttio kritstreck innebar ungefär 141 render encoders och 70 helskärmskompositioner.
Låt
Varje kritstreck hade dessutom en fast render-pass-overhead, så även den kostnaden växte linjärt med
Mätningarna gjordes på en 12.9-inch iPad Pro (5th generation, M1) med iPadOS 18.6.2. Vi jämförde GPU-tidsstämplar från samma viewport före och efter ändringen och använde command-buffer-tidsstämplar i samma Debug device build på just den här iPaden — nedan kallad LucasPad. Intervallen nedan är typiska variationer från loggar över flera bildrutor, inte utfästelser om bildfrekvens i en version för användare. Med 70 synliga kritstreck krävde en bildruta vanligtvis 52–60 ms GPU-tid; i ett område med ungefär 120 streck steg GPU-tiden till 77–80 ms.
Om vi uppskattar utifrån arean hos helskärmsrektanglarna i scratch- och composite-passen växte det teoretiska arbetsomfånget per bildruta från ungefär 783 million pixels till 1.34 billion pixels. Värdet är summan av rektangelareorna och motsvarar inte antalet fragmentanrop, antalet lästa/skrivna byte i videominnet eller GPU:ns hårdvaruräknare. Metals fast clear, attachment load/store och passväxlingar styrs fortfarande av GPU:n och drivrutinen.
Det förklarar också varför tomma områden förblev mjuka. Visibility culling hoppar över streck utanför viewporten; i ett tomt område ligger
Ett felaktigt svar vid 0.85 ms
Appen hade redan en bakad bläcktextur för hela sidan vid två pixlar per punkt. Vi försökte visa den texturen direkt under skrivning, panorering och zoomning och behöll bara det aktuella Apple Pencil-strecket som live-vektor; när interaktionen var slut skulle ytterligare en bildruta rendera om vektorresultatet i hög upplösning.
Metoden fungerade mycket bra. I samma täta område vid 300% zoom sjönk GPU-tiden till 0.84–0.85 ms och växte inte längre med antalet befintliga kritstreck.
Problemet på den faktiska enheten var lika tydligt. Vid 300% zoom behövdes ungefär sex skärmpixlar per punkt, men cachen gav bara två. I samma ögonblick som Apple Pencil trycktes mot ytan blev alla befintliga streck en mjuk bild med låg upplösning; när Pencil lyftes återgick de till full tydlighet.
Testaren sa en sak: ”När jag skriver blir hela arbetsytan suddig. Den blir tydlig igen så fort jag släpper.”
Optimeringen togs bort. 0.85 ms var det lägsta uppmätta resultatet, men det var inte ett godtagbart kritverktyg. Befintliga streck är en del av skrivfeedbacken; deras tydlighet kan inte ändras när pennan sätts mot ytan.
Begränsa varje kritstreck till sin egen rektangel
Den slutliga korrigeringen behöll scratch per streck och komposition per streck och minskade bara pixelarbetets omfattning. Varje streck hade redan en canvas-bounding box som härleddes från föreningen av alla stämpelradier. Renderaren transformerar den här bounding boxen till den aktuella viewportens drawable-koordinater och lägger till två pixlar för antialiasingmarginal:
Samma scissor-rektangel används för tre saker: rensa scratch, rita strecket och kompositera resultatet tillbaka till huvudytan.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Samma logik används också för baking och partial replay på 4096²-bläcktexturen, så att högzoomvisningen och det konsoliderade bläcklagret inte skapar två olika kritbeteenden.
Två detaljer är lätta att missa här.
För det första sker en render-pass loadAction = .clear under attachment-laddningen och begränsas inte av rasteriseringsscissorn. Om vi fortsatte använda den skulle hela scratch-texturen fortfarande rensas. Det fasta passet använder .dontCare och ritar sedan en clear_fragment inom scissorn. Den här rektangeln skrivs därefter helt, och composite läser bara samma rektangel, så det gamla attachment-innehållet behöver inte läsas in.
För det andra måste den yttre scissorn återställas efter att varje kritstrecks komposition är klar. Om den här raden för återställning av tillstånd utelämnas fortsätter efterföljande pennor, bilder eller markeringar att klippas av det föregående kritstreckets gränser och ser ut som saknade streck eller bilder.
Kritans korn samplas fortfarande från absoluta canvas-koordinater och inte från lokala UV-koordinater. Att flytta scissorn ändrar bara vilka pixlar GPU:n behandlar; det ändrar inte vilken position i korntexturen varje pixel läser från. Därför skapar intilliggande rektanglar inga texturskarvar, och när arbetsytan dras glider inte kornet.
Om vi bara räknar pixelbelastningen ligger det nya arbetsomfånget nära
där
Varför kritstreck med samma färg inte slogs ihop i samma paket
De flesta kritstreck på sidan har samma färg och densitet, och det är lockande att rita dussintals streck i scratch på en gång och kompositera bara en gång. Det skulle minska antalet render-pass ytterligare, men ändra färg- och kornsemantiken där strecken överlappar.
Tänk på ett avsiktligt förenklat fall: två streck delar samma grain-gate-värde
medan resultatet av att först slå ihop kropparna och sedan använda en enda gate blir
Skillnaden är
Exakt paketering kräver att vi bevisar att streckens pixlar är ömsesidigt disjunkta eller att vi tilldelar varje streck en egen atlasregion och kompositera i den ursprungliga ordningen. Den slutliga enhetsacceptansen behöll scissor-metoden, så vi införde inte någon atlas eller den tillhörande hanteringskomplexiteten i den här omgången.
Från en miljard pixlar tillbaka till några miljoner
Slutliga enhetsmätningar:
| Situation | Före korrigeringen | Exakt scissor |
|---|---|---|
| 70 synliga kritstreck, 300% zoom, skrivning | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 synliga kritstreck, 300% zoom | GPU 77–80 ms | 13.7–15.6 ms |
| Teoretiskt rektangelomfång per bildruta (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
Vi verifierade också klippningsgränserna med 3,452 streck och 202,710 samplingspunkter från ett enhetsdokument. Vid zoomnivåerna 0.5×, 1×, 2×, 3×, 5× och 8× genererades 186,408 viewportfall; varje point sprite som kunde ge icke-nolltäckning låg inom den beräknade scissorn. Kontrollen omfattade canvasens kanter, viewportens kanter och olika offsetkombinationer.
Den slutliga koden växlar inte till en lågupplöst LOD baserat på interaktionstillstånd. Vid låg zoom visas fortfarande hela sidans bläcktextur; vid hög zoom ritas synliga streck fortfarande om som vektorer. På samma sida av tröskeln ersätter pennkontakt och panorering inte befintliga streck med en annan tydlighetsnivå. Under vektoromritning vid hög zoom täcker varje kritstrecks scratch-rensning och komposition bara dess egen bounding box på skärmen.
GPU-tid fångade inte tydlighet
GPU-prestandaproblem skalar inte nödvändigtvis med den mest synliga mängden i datastrukturen. I det här fallet var 3,571 indata-punkter en enkel misstänkt; bildrutans tid bestämdes av helskärmsarbetet som utlöstes av vart och ett av de 70 kritstrecken, tillsammans med render-pass-växlingarna.
Visuell semantik begränsade också de tillgängliga optimeringarna. Scratch per streck, den ursprungliga kompositionsordningen och absoluta canvas-koordinater för kornet kunde inte tas bort godtyckligt. Samma färg och samma densitet betyder bara att parametrarna matchar — det bevisar inte att överlappande resultat kan slås ihop.
Feedback från den riktiga enheten avvisade versionen med den lägsta GPU-tiden. Kommentaren ”hela arbetsytan blir suddig” uttryckte produktbegränsningen som mätningen ensam inte hade fångat: när Apple Pencil trycks mot ytan ser användaren också de befintliga strecken.
Den slutliga versionen introducerar inget nytt cachelager för interaktion och minskar inte tydligheten. Vektoromritning vid hög zoom begränsar helt enkelt varje kritstrecks arbete till dess egen bounding box på skärmen. Efter att den laddats på LucasPad igen blev feedbacken: ”Det ser toppen ut.”