Lulucat

Απόδοση του εργαλείου κιμωλίας: από τα περάσματα πλήρους οθόνης στα ορθογώνια scissor

Gaoge ZhangGaoge Zhang

Το εργαλείο κιμωλίας του Lulucat Notes επιβραδυνόταν σε πυκνές περιοχές χειρόγραφου. Το σημείο συμφόρησης δεν ήταν τα 3,571 δείγματα εισόδου, αλλά τα 70 περάσματα scratch πλήρους οθόνης ανά frame. Μια απορριφθείσα cache χαμηλής ανάλυσης και ένα scissor rectangle ανά πινελιά εξηγούν τα υπόλοιπα.

Περικομμένη προβολή του Lulucat Notes σε iPad σε μεγέθυνση 255%, με κόκκινη και μπλε γραφή κιμωλίας ενός κλασικού κινεζικού αποσπάσματος και ορατά τμήματα της γραμμής εργαλείων της εφαρμογής.

Κόκκινη και μπλε κιμωλία στη δοκιμαστική build της συσκευής, 155 πινελιές σε μεγέθυνση 255%.

Το εργαλείο κιμωλίας στο Lulucat Notes είχε ένα συγκεκριμένο πρόβλημα απόδοσης: η γραφή σε κενή περιοχή έμοιαζε ομαλή, αλλά όταν η γραφίδα περνούσε σε περιοχή ήδη γεμάτη με πινελιές κιμωλίας, η μύτη του μολυβιού έμενε πίσω. Η συνέχιση της γραφής στην ίδια περιοχή επιβράδυνε σταδιακά και τη μετακίνηση του καμβά.

Μία σελίδα συνηθισμένου χειρόγραφου αρκούσε για να εμφανιστεί το πρόβλημα: μεγέθυνση 300%, 70 ορατές πινελιές κιμωλίας στην τοπική περιοχή και συνολικά 3,571 σημεία δειγμάτων εισόδου. Οι κενές περιοχές παρέμεναν ομαλές· αργή γινόταν μόνο η περιοχή όπου συγκεντρώνονταν οι πινελιές.

Μετά τη διόρθωση, η ίδια σελίδα μπορεί να συνεχίσει να δέχεται νέα γραφή σε μεγέθυνση 255%, ενώ οι υπάρχουσες πινελιές διατηρούν πλήρη ευκρίνεια τόσο κατά την επαφή της γραφίδας όσο και κατά τη μετακίνηση.

Το Lulucat Notes σε iPad σε μεγέθυνση 255%, με γραφή κιμωλίας σε κόκκινο και μπλε. Το κείμενο γράφει «天行健,君子以自强不息;地势坤,君子以厚德载物» — ένα κλασικό κινεζικό απόσπασμα. Μια μπλε μασκότ του Lulucat βρίσκεται επάνω δεξιά. Η κάτω γραμμή εργαλείων δείχνει πλήθος πινελιών 155, Save, Clear και ρυθμιστικό μεγέθυνσης 255%.

Το στιγμιότυπο από τη δοκιμή στη συσκευή, με 155 πινελιές συνολικά. Σε αυτό το επίπεδο μεγέθυνσης, η ευκρίνεια των υπαρχουσών πινελιών δεν αλλάζει προσωρινά ούτε κατά την επαφή της γραφίδας ούτε κατά τη μετακίνηση.

Γιατί η κιμωλία χρειάζεται υφή scratch

Ένα συνηθισμένο μολύβι μπορεί να κάνει composite κάθε κυκλική σφραγίδα απευθείας πάνω στην υφή μελανιού με source-over blending. Η κιμωλία προσθέτει ένα επίπεδο grain-gating: ο renderer συσσωρεύει πρώτα την κάλυψη και το βάθος του σώματος για ολόκληρη την πινελιά, έπειτα χρησιμοποιεί μια σταθερή υφή κόκκων για να καθορίσει ποιες θέσεις θα δεχτούν σκόνη κιμωλίας και τέλος κάνει composite το αποτέλεσμα πάνω στο υπάρχον μελάνι.

Αυτή η υφή scratch απομονώνει μία πινελιά κιμωλίας. Η απομόνωση έχει σημασία επειδή οι σφραγίδες μέσα στην ίδια πινελιά επικαλύπτονται έντονα· αν κάθε σφραγίδα περνούσε ξεχωριστά από grain gate, η κεντρική γραμμή της πινελιάς θα συσσώρευε επανειλημμένα χρώμα και οι πόροι της κιμωλίας θα μετακινούνταν μαζί με την πυκνότητα δειγματοληψίας.

Σε υψηλά επίπεδα μεγέθυνσης, το Lulucat Notes επανασχεδιάζει τις διανυσματικές πινελιές που είναι ορατές στο τρέχον viewport. Η παλιά υλοποίηση εκτελούσε τα παρακάτω βήματα για κάθε ορατή πινελιά κιμωλίας:

  1. Τερμάτιζε τον κύριο render encoder·
  2. Καθάριζε την υφή scratch·
  3. Σχεδίαζε αυτή την πινελιά κιμωλίας στο scratch·
  4. Άνοιγε ξανά τον κύριο render encoder·
  5. Έκανε composite το scratch πίσω στο drawable με ένα τρίγωνο πλήρους οθόνης.

Η σημασιολογία μίας πινελιάς ήταν σωστή, αλλά το εύρος της εργασίας ήταν πολύ μεγαλύτερο. Το drawable του iPad ήταν 2732×2048 — περίπου 5.6 εκατομμύρια pixel. Κάθε πινελιά κιμωλίας ενεργοποιούσε ένα scratch pass και ένα composite πλήρους οθόνης. 70 πινελιές κιμωλίας σήμαιναν περίπου 141 render encoder και 70 composite πλήρους οθόνης.

Έστω ότι ο αριθμός των ορατών πινελιών κιμωλίας είναι και ο αριθμός pixel του drawable είναι . Αν εξετάσουμε μόνο την εργασία που κλιμακώνεται με την κάλυψη pixel, η παλιά υλοποίηση ήταν περίπου

Κάθε πινελιά κιμωλίας είχε επίσης σταθερό overhead render pass, οπότε και αυτό το κόστος κλιμακωνόταν γραμμικά με το . Τα 3,571 σημεία εισόδου συνεισέφεραν μόνο ένα δευτερεύον κόστος. Αυτό που κλιμακωνόταν με τον τοπικό αριθμό πινελιών ήταν το εύρος εργασίας πλήρους οθόνης που ενεργοποιούσε κάθε πινελιά.

Οι μετρήσεις έγιναν σε iPad Pro 12.9-inch (5ης γενιάς, M1) με iPadOS 18.6.2. Συγκρίναμε GPU timestamps από το ίδιο viewport πριν και μετά την αλλαγή, χρησιμοποιώντας command-buffer timestamps στο ίδιο Debug device build στο συγκεκριμένο iPad — παρακάτω το αποκαλούμε LucasPad. Τα παρακάτω εύρη είναι τυπικές διακυμάνσεις από multi-frame logs και όχι δεσμεύσεις για frame rate έκδοσης προς διανομή. Με 70 ορατές πινελιές κιμωλίας, ένα frame απαιτούσε συνήθως 52–60 ms χρόνου GPU· σε περιοχή με περίπου 120 πινελιές, ο χρόνος GPU αυξανόταν σε 77–80 ms.

Με εκτίμηση βάσει του εμβαδού του ορθογωνίου πλήρους οθόνης των scratch και composite pass, το θεωρητικό εύρος εργασίας ανά frame αυξήθηκε από περίπου 783 εκατομμύρια pixel σε 1.34 δισεκατομμύρια pixel. Ο αριθμός αυτός είναι το άθροισμα των εμβαδών των ορθογωνίων και δεν ισοδυναμεί με αριθμό fragment invocations, bytes ανάγνωσης/εγγραφής video memory ή GPU hardware counters. Το fast clear του Metal, το attachment load/store και η εναλλαγή pass παραμένουν υπό τον έλεγχο της GPU και του driver.

Αυτό εξηγεί επίσης γιατί οι κενές περιοχές παρέμεναν ομαλές. Το visibility culling παραλείπει τις πινελιές έξω από το viewport· το σε κενή περιοχή είναι κοντά στο μηδέν, ενώ το σε πυκνή περιοχή συνεχίζει να αυξάνεται.

Μια λανθασμένη απάντηση στα 0.85 ms

Η εφαρμογή διέθετε ήδη μια προκατασκευασμένη texture μελανιού ολόκληρης σελίδας, σε δύο pixel ανά point. Δοκιμάσαμε να εμφανίζουμε απευθείας αυτή την texture κατά τη γραφή, τη μετακίνηση και το zoom, κρατώντας μόνο την τρέχουσα πινελιά του Apple Pencil ως ζωντανό vector· όταν τελείωνε η αλληλεπίδραση, ένα επιπλέον frame έκανε re-render το vector αποτέλεσμα υψηλής ανάλυσης.

Αυτή η προσέγγιση είχε πολύ καλή απόδοση. Στην ίδια πυκνή περιοχή σε μεγέθυνση 300%, ο χρόνος GPU έπεσε στα 0.84–0.85 ms και δεν αυξανόταν πλέον με τον αριθμό των υπαρχουσών πινελιών κιμωλίας.

Το πρόβλημα στην πραγματική συσκευή ήταν εξίσου ξεκάθαρο. Σε μεγέθυνση 300% χρειάζονταν περίπου έξι pixel οθόνης ανά point, αλλά η cache παρείχε μόνο δύο. Μόλις το Apple Pencil άγγιζε την επιφάνεια, όλες οι υπάρχουσες πινελιές γίνονταν μια απαλή εικόνα χαμηλής ανάλυσης· όταν σηκωνόταν το Pencil, επανέρχονταν απότομα σε πλήρη ευκρίνεια.

Ο δοκιμαστής είπε ένα πράγμα: «Όταν γράφω, όλος ο καμβάς γίνεται θολός. Καθαρίζει μόλις αφήσω το μολύβι.»

Αφαιρέσαμε τη βελτιστοποίηση. Τα 0.85 ms ήταν το χαμηλότερο μετρημένο αποτέλεσμα, αλλά δεν ήταν αποδεκτό εργαλείο κιμωλίας. Οι υπάρχουσες πινελιές αποτελούν μέρος της ανάδρασης της γραφής· η ευκρίνειά τους δεν μπορεί να αλλάζει κατά την επαφή της γραφίδας.

Περιορισμός κάθε πινελιάς κιμωλίας στο δικό της ορθογώνιο

Η υλοποιημένη διόρθωση διατήρησε το scratch ανά πινελιά και το compositing ανά πινελιά και μείωσε μόνο το εύρος της εργασίας pixel. Κάθε πινελιά είχε ήδη ένα canvas bounding box που προέκυπτε από την ένωση όλων των ακτίνων των σφραγίδων της. Ο renderer μετασχηματίζει αυτό το bounding box στις drawable coordinates του τρέχοντος viewport και το επεκτείνει κατά δύο pixel για περιθώριο antialiasing:

Το ίδιο scissor rectangle χρησιμοποιείται έπειτα για τρία πράγματα: καθαρισμό του scratch, σχεδίαση της πινελιάς και compositing του αποτελέσματος πίσω στην κύρια επιφάνεια.

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

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

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

Η ίδια λογική χρησιμοποιείται επίσης για την προκατασκευή και τη μερική επαναπαραγωγή στη texture μελανιού 4096², έτσι ώστε η προβολή σε υψηλό zoom και η σταθεροποιημένη στρώση μελανιού να μην παράγουν δύο διαφορετικές συμπεριφορές κιμωλίας.

Υπάρχουν δύο λεπτομέρειες που εύκολα παραβλέπονται.

Πρώτον, το loadAction = .clear ενός render pass συμβαίνει στο στάδιο φόρτωσης του attachment και δεν περιορίζεται από το rasterization scissor. Αν συνεχίζαμε να το χρησιμοποιούμε, θα καθάριζε και πάλι ολόκληρη τη scratch texture. Το διορθωμένο pass χρησιμοποιεί .dontCare και έπειτα σχεδιάζει ένα clear_fragment μέσα στο scissor. Αυτό το ορθογώνιο γράφεται στη συνέχεια ολόκληρο και το composite διαβάζει μόνο το ίδιο ορθογώνιο, επομένως δεν χρειάζεται να φορτωθούν παλιά περιεχόμενα του attachment.

Δεύτερον, μετά την ολοκλήρωση του composite κάθε πινελιάς κιμωλίας πρέπει να επαναφέρεται το εξωτερικό scissor. Αν παραλειφθεί αυτή η γραμμή επαναφοράς κατάστασης, τα επόμενα μολύβια, οι εικόνες ή οι επιλογές θα συνεχίσουν να κόβονται από τα όρια της προηγούμενης πινελιάς κιμωλίας και θα φαίνονται σαν πινελιές ή εικόνες που λείπουν.

Το grain της κιμωλίας εξακολουθεί να δειγματοληπτεί από απόλυτες συντεταγμένες του καμβά και όχι από τοπικά UV μέσα στο ορθογώνιο. Η μετακίνηση του scissor αλλάζει μόνο τα pixel που επεξεργάζεται η GPU· δεν αλλάζει τη θέση της grain texture από την οποία διαβάζει κάθε pixel. Έτσι τα γειτονικά ορθογώνια δεν δημιουργούν ραφές στην texture και η μετακίνηση του καμβά δεν κάνει το grain να μετατοπίζεται.

Αν εξετάσουμε μόνο το pixel workload, το νέο εύρος εργασίας είναι περίπου

όπου είναι το εμβαδόν του bounding box της πινελιάς κιμωλίας , ευθυγραμμισμένου με τους άξονες, στην τρέχουσα οθόνη. Ο αριθμός των render encoder δεν μειώθηκε, αλλά κάθε clear και composite περιορίζεται πλέον από το screen bounding box της πινελιάς.

Γιατί δεν κάναμε batch τις πινελιές κιμωλίας ίδιου χρώματος

Οι περισσότερες πινελιές κιμωλίας στη σελίδα έχουν το ίδιο χρώμα και την ίδια πυκνότητα, και είναι δελεαστικό να σχεδιάσουμε δεκάδες πινελιές στο scratch μαζί και να κάνουμε composite μόνο μία φορά. Αυτό θα μείωνε ακόμη περισσότερο τα render pass, αλλά θα άλλαζε τη σημασιολογία χρώματος και grain στις περιοχές επικάλυψης.

Ας εξετάσουμε μια σκόπιμα απλοποιημένη περίπτωση: δύο πινελιές έχουν την ίδια τιμή grain-gate σε ένα συγκεκριμένο pixel, με καλύψεις σώματος και . Στον πραγματικό shader, το gate εξαρτάται επίσης από το βάθος πίεσης κάθε πινελιάς· αυτή η απλούστερη περίπτωση αρκεί για να δείξει ότι το batching γενικά δεν είναι ισοδύναμο. Το τρέχον per-stroke compositing παράγει

ενώ η συγχώνευση των σωμάτων πρώτα και η εφαρμογή ενός gate στη συνέχεια παράγει

Η διαφορά είναι . Όταν δύο πινελιές επικαλύπτονται και το grain gate δεν είναι ούτε καθαρό μηδέν ούτε καθαρό ένα, τα αποτελέσματα διαφέρουν. Το άμεσο batching θα άλλαζε τον τρόπο με τον οποίο πέφτει η σκόνη κιμωλίας στις διασταυρώσεις.

Το ακριβές batching απαιτεί απόδειξη ότι τα pixel των πινελιών είναι αμοιβαία ξένα ή εκχώρηση ανεξάρτητης περιοχής atlas για κάθε πινελιά και compositing με την αρχική σειρά. Ο έλεγχος αποδοχής στη συσκευή διατήρησε την προσέγγιση scissor, οπότε αυτός ο γύρος δεν πρόσθεσε atlas ούτε την πολυπλοκότητα διαχείρισής του.

Από ένα δισεκατομμύριο pixel πίσω σε λίγα εκατομμύρια

Οι μετρήσεις στη συσκευή:

ΣενάριοΠριν από τη διόρθωσηΑκριβές scissor
70 ορατές πινελιές κιμωλίας, μεγέθυνση 300%, γραφήGPU 52–60 ms≈ 9–10 ms
≈ 121 ορατές πινελιές κιμωλίας, μεγέθυνση 300%GPU 77–80 ms13.7–15.6 ms
Θεωρητικό εύρος ορθογωνίων ανά frame (scratch + composite)783 M–1.34 B pixel≈ 1.7 M–3 M pixel

Επαληθεύσαμε επίσης τα όρια clipping με 3,452 πινελιές και 202,710 σημεία δειγμάτων από έγγραφο της συσκευής. Σε zoom 0.5×, 1×, 2×, 3×, 5× και 8× δημιουργήθηκαν 186,408 περιπτώσεις viewport· κάθε point sprite που μπορούσε να παράγει μη μηδενική κάλυψη βρισκόταν μέσα στο υπολογισμένο scissor. Ο έλεγχος κάλυψε άκρα καμβά, άκρα viewport και διάφορους συνδυασμούς offset.

Η επιλεγμένη υλοποίηση δεν μεταβαίνει σε LOD χαμηλής ανάλυσης με βάση την κατάσταση αλληλεπίδρασης. Τα χαμηλά επίπεδα zoom εξακολουθούν να εμφανίζουν τη texture μελανιού ολόκληρης της σελίδας· τα υψηλά επίπεδα zoom εξακολουθούν να επανασχεδιάζουν τις ορατές πινελιές ως vector. Στην ίδια πλευρά του threshold, η επαφή της γραφίδας και η μετακίνηση δεν αντικαθιστούν τις υπάρχουσες πινελιές με διαφορετικό επίπεδο ευκρίνειας. Κατά το vector redraw σε υψηλό zoom, το scratch clear και το composite κάθε πινελιάς κιμωλίας καλύπτουν μόνο το δικό της screen bounding box.

Ο χρόνος GPU δεν κατέγραψε την ευκρίνεια

Τα προβλήματα απόδοσης GPU δεν κλιμακώνονται κατ’ ανάγκη με το πιο εμφανές μέγεθος στη δομή δεδομένων. Σε αυτή την περίπτωση, τα 3,571 σημεία εισόδου ήταν ένας εύκολος ύποπτος· αυτό που καθόριζε τον χρόνο του frame ήταν η εργασία πλήρους οθόνης που ενεργοποιούσε κάθε μία από τις 70 πινελιές κιμωλίας, μαζί με την εναλλαγή των render pass.

Η οπτική σημασιολογία περιόριζε επίσης τις διαθέσιμες βελτιστοποιήσεις. Το per-stroke scratch, η αρχική σειρά compositing και οι απόλυτες συντεταγμένες grain του καμβά δεν μπορούσαν να αφαιρεθούν αυθαίρετα. Το ίδιο χρώμα και η ίδια πυκνότητα σημαίνουν μόνο ότι οι παράμετροι ταιριάζουν — δεν αποδεικνύουν ότι τα επικαλυπτόμενα αποτελέσματα μπορούν να συγχωνευτούν.

Η ανάδραση από την πραγματική συσκευή απέρριψε την έκδοση με τον χαμηλότερο χρόνο GPU. Η παρατήρηση «όλος ο καμβάς γίνεται θολός» έδωσε τον περιορισμό προϊόντος που η μέτρηση από μόνη της δεν είχε εκφράσει: όταν το Apple Pencil ακουμπά την επιφάνεια, οι χρήστες παρατηρούν επίσης τις υπάρχουσες πινελιές.

Η επιλεγμένη υλοποίηση δεν εισάγει νέα στρώση cache αλληλεπίδρασης και δεν μειώνει την ευκρίνεια. Το vector redraw σε υψηλό zoom απλώς περιορίζει την εργασία κάθε πινελιάς κιμωλίας στο δικό της screen bounding box. Αφού φορτώθηκε ξανά στο LucasPad, η ανάδραση έγινε: «Δείχνει υπέροχο.»