Lulucat

Kridtværktøjets ydeevne: fra fuldskærmspassager til scissor-rektangler

Gaoge ZhangGaoge Zhang

Lulucat Notes' kridt-værktøj blev langsommere i tætte håndskriftsområder. Flaskehalsen var ikke de 3,571 inputprøver — det var 70 fuldskærms-scratchpassager pr. frame. En afvist cache i lav opløsning og et scissor-rektangel pr. streg fortæller resten af historien.

Beskåret visning af Lulucat Notes på en iPad ved 255% zoom, der viser rød og blå kridthåndskrift med et klassisk kinesisk afsnit og dele af appens værktøjslinje.

Rødt og blåt kridt i test-buildet til enheden, 155 streger ved 255% zoom.

Kridt-værktøjet i Lulucat Notes havde et meget bestemt ydeevneproblem: Det føltes glat at skrive i et tomt område, men når man bevægede sig ind i et område, der allerede var fyldt med kridtstreger, sakkede pennespidsen bagud. Hvis man fortsatte med at skrive i det samme område, blev panorering af lærredet også gradvist langsommere.

En enkelt side med almindelig håndskrift var nok til at udløse problemet: 300% zoom, 70 synlige kridtstreger i lokalområdet og i alt 3,571 inputpunkter. Tomme områder forblev flydende; det var kun området med de koncentrerede streger, der blev langsomt.

Efter rettelsen kan den samme side fortsat modtage ny skrift ved 255% zoom, og eksisterende streger bevarer deres fulde klarhed både under pen-down og panorering.

Lulucat Notes på en iPad ved 255% zoom, der viser kridthåndskrift i rødt og blåt. Teksten lyder "天行健,君子以自强不息;地势坤,君子以厚德载物" — et klassisk kinesisk afsnit. En blå Lulucat-maskot sidder øverst til højre. Den nederste værktøjslinje viser et stregtal på 155, Save, Clear og en zoomskyder på 255%.

Skærmbilledet fra enhedstesten, 155 streger i alt. Ved dette zoomniveau skifter klarheden i de eksisterende streger ikke midlertidigt, hverken under pen-down eller panorering.

Hvorfor kridt har brug for en scratch-tekstur

En almindelig pen kan compositere hvert cirkulært stempel direkte på blæktexturet med source-over-blanding. Kridt tilføjer et lag til kornstyring: Rendereren akkumulerer først kropsdækning og dybde for en hel streg, bruger derefter en fast korntekstur til at afgøre, hvilke positioner der modtager kridtstøv, og compositer til sidst resultatet på den eksisterende blæktexture.

Denne scratch-tekstur isolerer en enkelt kridtstreg. Isolationen er vigtig, fordi stemplerne i den samme streg overlapper kraftigt; hvis hvert stempel blev kornstyret individuelt, ville stregens centerlinje samle farve gentagne gange, og kridtporerne ville flytte sig med sample-tætheden.

Ved høje zoomniveauer tegner Lulucat Notes de vektortegninger, der er synlige i den aktuelle viewport, igen. Den gamle implementering udførte disse trin for hver synlig kridtstreg:

  1. Afslut den primære render encoder;
  2. Ryd scratch-texturen;
  3. Tegn denne kridtstreg ind i scratch;
  4. Åbn den primære render encoder igen;
  5. Composit scratch tilbage på drawable med en fuldskærmstrekant.

Semantikken for én streg var korrekt, men arbejdsområdet var langt større. iPadens drawable var 2732×2048 — omtrent 5.6 millioner pixels. Hver kridtstreg udløste én scratch-pass og én fuldskærmscomposite. 70 kridtstreger betød cirka 141 render encodere og 70 fuldskærmscomposites.

Lad antallet af synlige kridtstreger være og antallet af pixels i drawable være . Hvis vi kun ser på det arbejde, der skalerer med pixeldækningen, lå den gamle implementering tæt på

Hver kridtstreg havde også en fast overhead for renderpasset, så den omkostning skalerede ligeledes lineært med . De 3,571 inputpunkter bidrog kun med en sekundær omkostning. Det, der skalerede med antallet af lokale streger, var det fuldskærmsarbejde, som hver streg udløste.

Målingerne blev taget på en 12.9-tommers iPad Pro (5. generation, M1) med iPadOS 18.6.2. Vi sammenlignede GPU-tidsstempler fra den samme viewport før og efter ændringen ved hjælp af command-buffer-tidsstempler i den samme Debug-device-build på netop denne iPad — nedenfor kalder vi den LucasPad. Intervallerne herunder er typiske udsving fra logs over flere frames, ikke et løfte om frame rate i en build til distribution. Ved 70 synlige kridtstreger krævede en enkelt frame typisk 52–60 ms GPU-tid; i et område med omkring 120 streger steg GPU-tiden til 77–80 ms.

Hvis man estimerer arbejdsområdet pr. frame ud fra rektangelarealet for scratch- og composite-passagerne på hele skærmen, voksede det teoretiske område fra cirka 783 millioner pixels til 1.34 milliarder pixels. Tallet er summen af rektangelarealer og svarer ikke til antal fragmentinvokationer, bytes læst/skrevet i videohukommelsen eller GPU-hardwaretællere. Metals fast clear, attachment load/store og passkift styres stadig af GPU’en og driveren.

Det forklarer også, hvorfor tomme områder forblev glatte. Visibility culling springer streger uden for viewporten over; i et tomt område er tæt på nul, mens i et tæt område fortsætter med at stige.

Et forkert svar ved 0.85 ms

Appen havde allerede en inktexture for hele siden, forbagt ved to pixels pr. point. Vi prøvede at vise denne texture direkte under skrivning, panorering og zoom og kun beholde den aktuelle Apple Pencil-streg som live-vektor; når interaktionen sluttede, ville én ekstra frame gengive vektorresultatet i høj opløsning igen.

Denne tilgang fungerede meget godt. I det samme tætte område ved 300% zoom faldt GPU-tiden til 0.84–0.85 ms og voksede ikke længere med antallet af eksisterende kridtstreger.

Problemet på den faktiske enhed var lige så tydeligt. Ved 300% zoom var der brug for omtrent seks skærmpixels pr. point, men cachen leverede kun to. I det øjeblik Apple Pencil rørte overfladen, blev alle eksisterende streger til et blødt billede i lav opløsning; når Apple Pencil blev løftet, sprang de tilbage til fuld klarhed.

Testeren sagde én ting: “Når jeg skriver, bliver hele lærredet sløret. Det bliver klart igen, så snart jeg slipper.”

Optimeringen blev fjernet. 0.85 ms var det laveste målte resultat, men det var ikke et acceptabelt kridt-værktøj. Eksisterende streger er en del af skrivefeedbacken; deres klarhed må ikke ændre sig ved pen-down.

Hver kridtstreg begrænses til sit eget rektangel

Den implementerede rettelse bevarede scratch pr. streg og compositing pr. streg og reducerede kun deres pixelarbejdsområde. Hver streg havde allerede en afgrænsningsboks for lærredet, der var afledt af unionen af alle dens stempelradier. Rendereren transformerer denne afgrænsningsboks til drawable-koordinater i den aktuelle viewport og paddinger den med to pixels som antialiasing-margin:

Det samme scissor-rektangel bruges derefter til tre ting: at rydde scratch, tegne stregen og compositere resultatet tilbage på hovedoverfladen.

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

Den samme logik bruges også til forbagning og delvis afspilning på 4096²-inktexturet, så high-zoom-visningen og det færdige ink-lag ikke giver to forskellige kridtadfærd.

Der er to detaljer, som er nemme at overse.

For det første sker loadAction = .clear for et renderpass under attachment-loadfasen og er ikke begrænset af rasteriserings-scissoren. Hvis vi fortsatte med at bruge det, ville hele scratch-texturen stadig blive ryddet. Det faste pass bruger .dontCare og tegner derefter en clear_fragment inden for scissoren. Dette rektangel skrives efterfølgende helt, og compositen læser kun det samme rektangel, så det gamle attachment-indhold behøver ikke indlæses.

For det andet skal den ydre scissor gendannes, når compositen af hver kridtstreg er færdig. Hvis denne linje til gendannelse af tilstanden udelades, vil efterfølgende penne, billeder eller markeringer fortsat blive klippet af den forrige kridtstregs grænser og se ud som manglende streger eller billeder.

Kridtkornet samples stadig fra absolutte canvas-koordinater i stedet for lokale UV’er i rektanglet. Når scissoren flyttes, ændres kun de pixels, GPU’en behandler; den ændrer ikke, hvilken kornteksturposition den enkelte pixel læser fra. Derfor giver naborektangler ingen tekstursømme, og kornet driver ikke, når lærredet trækkes.

Hvis vi kun ser på pixelarbejdet, ligger det nye arbejdsområde tæt på

hvor er arealet af den aksejusterede afgrænsningsboks for den ‘te kridtstreg på den aktuelle skærm. Antallet af render encodere er ikke faldet, men hver clear og composite er nu afgrænset af stregens afgrænsningsboks på skærmen.

Hvorfor kridtstreger med samme farve ikke blev batchet

De fleste kridtstreger på siden har samme farve og tæthed, og det er fristende at tegne snesevis af streger ind i scratch på én gang og kun compositere én gang. Det ville reducere antallet af renderpassager yderligere, men ændre farve- og kornsemantikken i overlappende områder.

Se på et bevidst forenklet tilfælde: To streger deler den samme grain-gate-værdi i en bestemt pixel og har kropsdækningerne og . I den faktiske shader afhænger gaten også af trykdybden for hver streg; dette enklere tilfælde er tilstrækkeligt til at vise, at batching ikke generelt er ækvivalent. Den nuværende compositing pr. streg giver

mens en sammenlægning af kroppene først og derefter én gate giver

Forskellen er . Når to streger overlapper, og grain-gaten hverken er ren nul eller ren ét, er resultaterne forskellige. Direkte batching ville ændre, hvordan kridtstøv lander ved krydsninger.

Præcis batching kræver, at man beviser, at stregpixels er indbyrdes disjunkte, eller tildeler et uafhængigt atlasområde til hver streg og compositerer dem i den oprindelige rækkefølge. Accepttesten på enheden beholdt scissor-tilgangen, så denne runde tilføjede hverken et atlas eller kompleksiteten ved at administrere det.

Fra en milliard pixels tilbage til få millioner

Målingerne på enheden:

ScenarieFør rettelsenPræcis scissor
70 synlige kridtstreger, 300% zoom, skrivningGPU 52–60 ms≈ 9–10 ms
≈ 121 synlige kridtstreger, 300% zoomGPU 77–80 ms13.7–15.6 ms
Teoretisk rektangelområde pr. frame (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Vi kontrollerede også klipningsgrænserne med 3,452 streger og 202,710 målepunkter fra et device-dokument. Ved 0.5×, 1×, 2×, 3×, 5× og 8× zoom blev der genereret 186,408 viewport-tilfælde; hvert point sprite, der kunne give non-zero coverage, lå inden for den beregnede scissor. Kontrollen dækkede lærredskanter, viewport-kanter og forskellige kombinationer af offsets.

Den valgte implementering skifter ikke til en LOD i lav opløsning baseret på interaktionstilstand. Lave zoomniveauer viser stadig inktexturet for hele siden; høje zoomniveauer tegner stadig synlige streger igen som vektorer. På samme side af tærsklen erstatter pen-down og panorering ikke eksisterende streger med et andet klarhedsniveau. Under vektorgentegning ved højt zoom dækker scratch-clear og composite for hver kridtstreg kun dens egen afgrænsningsboks på skærmen.

GPU-tid fangede ikke klarheden

GPU-ydeevneproblemer skalerer ikke nødvendigvis med den mest synlige størrelse i datastrukturen. I dette tilfælde var de 3,571 inputpunkter en oplagt mistænkt; det, der bestemte frametiden, var fuldskærmsarbejdet udløst af hver af de 70 kridtstreger sammen med skift mellem renderpassager.

Visuel semantik begrænsede også de mulige optimeringer. Scratch pr. streg, den oprindelige compositing-rækkefølge og absolutte canvas-koordinater for korn kunne ikke fjernes efter behag. Samme farve og samme tæthed betyder kun, at parametrene matcher — det beviser ikke, at overlappende resultater kan flettes.

Feedback fra den faktiske enhed afviste versionen med den laveste GPU-tid. Bemærkningen “hele lærredet bliver sløret” gav den produktbegrænsning, som målingen alene ikke havde udtrykt: Når Apple Pencil rører overfladen, observerer brugerne også de eksisterende streger.

Den valgte implementering indfører ikke et nyt interaktions-cachelag og reducerer ikke klarheden. Vektorgentegning ved højt zoom begrænser blot hver kridtstregs arbejde til dens egen afgrænsningsboks på skærmen. Efter at den igen blev indlæst på LucasPad, lød feedbacken: “Det ser godt ud.”