Performance des Kreidewerkzeugs: von Vollbild-Pässen zu Scissor-Rechtecken
Das Kreidewerkzeug von Lulucat Notes wurde in dichten Handschriftbereichen langsam. Der Engpass waren nicht die 3,571 Eingabe-Samples, sondern 70 Scratch-Pässe über den gesamten Bildschirm pro Frame. Ein verworfener Cache mit niedriger Auflösung und ein Scissor-Rechteck pro Strich erzählen den Rest.

Rote und blaue Kreide im Geräte-Test-Build, 155 Striche bei 255% Zoom.
Das Kreidewerkzeug in Lulucat Notes hatte ein bestimmtes Performanceproblem: Schreiben auf einer leeren Fläche fühlte sich flüssig an, doch beim Wechsel in einen Bereich, der bereits mit Kreidestrichen gefüllt war, blieb die Stiftspitze zurück. Wenn man in diesem Bereich weiterschrieb, wurde auch das Verschieben der Zeichenfläche allmählich langsamer.
Eine einzige Seite mit gewöhnlicher Handschrift reichte aus, um das Problem auszulösen: 300% Zoom, 70 sichtbare Kreidestriche im lokalen Bereich und insgesamt 3,571 Eingabe-Samplepunkte. Leere Bereiche blieben flüssig; langsam wurde nur der Bereich, in dem sich die Striche konzentrierten.
Nach der Korrektur kann dieselbe Seite bei 255% Zoom weiterhin neue Eingaben aufnehmen, und vorhandene Striche behalten sowohl beim Aufsetzen des Stifts als auch beim Verschieben ihre volle Klarheit.

Der Geräte-Test-Screenshot mit insgesamt 155 Strichen. Bei dieser Zoomstufe wechselt die Klarheit vorhandener Striche weder beim Aufsetzen des Stifts noch beim Verschieben vorübergehend.
Warum Kreide eine Scratch-Textur benötigt
Ein gewöhnlicher Stift kann jeden runden Stempel direkt mit Source-over-Blending auf die Tintentextur kompositieren. Kreide fügt eine Grain-Gating-Schicht hinzu: Der Renderer sammelt zunächst für einen vollständigen Strich die Körperabdeckung und Tiefe, verwendet anschließend eine feste Grain-Textur, um die Positionen zu bestimmen, die Kreidestaub erhalten, und kompositiert das Ergebnis schließlich auf die vorhandene Tinte.
Diese Scratch-Textur isoliert einen einzelnen Kreidestrich. Die Isolation ist wichtig, weil sich Stempel innerhalb desselben Strichs stark überlagern; würde jeder Stempel einzeln durch das Grain-Gating laufen, würde die Mittellinie des Strichs wiederholt Farbe ansammeln, und die Kreideporen würden sich mit der Sampling-Dichte verschieben.
Bei hohen Zoomstufen zeichnet Lulucat Notes die im aktuellen Viewport sichtbaren Vektorstriche neu. Die alte Implementierung führte für jeden sichtbaren Kreidestrich folgende Schritte aus:
- Den Haupt-Render-Encoder beenden;
- Die Scratch-Textur leeren;
- Diesen einzelnen Kreidestrich in den Scratch zeichnen;
- Den Haupt-Render-Encoder erneut öffnen;
- Den Scratch mit einem Vollbilddreieck zurück auf das Drawable kompositieren.
Die Semantik eines einzelnen Strichs war korrekt, aber der Arbeitsumfang war viel größer. Das Drawable des iPads hatte 2732×2048 Pixel — ungefähr 5.6 Millionen Pixel. Jeder Kreidestrich löste einen Scratch-Pass und eine Vollbild-Komposition aus. 70 Kreidestriche bedeuteten ungefähr 141 Render-Encoder und 70 Vollbild-Kompositionen.
Sei
Jeder Kreidestrich brachte außerdem einen festen Overhead des Render-Passes mit, sodass auch diese Kosten linear mit
Die Messungen wurden auf einem 12.9-inch iPad Pro (5. Generation, M1) mit iPadOS 18.6.2 vorgenommen. Wir verglichen GPU-Zeitstempel aus demselben Viewport vor und nach der Änderung, mit Command-Buffer-Zeitstempeln im selben Debug-Device-Build auf genau diesem iPad — im Folgenden LucasPad genannt. Die Bereiche unten sind typische Schwankungen aus Logs über mehrere Frames und kein Versprechen für die Bildrate eines zur Verteilung bestimmten Builds. Bei 70 sichtbaren Kreidestrichen benötigte ein einzelner Frame typischerweise 52–60 ms GPU-Zeit; in einem Bereich mit ungefähr 120 Strichen stieg die GPU-Zeit auf 77–80 ms.
Nach der Fläche des Vollbildrechtecks der Scratch- und Composite-Pässe wuchs der theoretische Arbeitsumfang pro Frame von ungefähr 783 Millionen Pixeln auf 1.34 Milliarden Pixel. Diese Zahl ist die Summe der Rechteckflächen und entspricht weder der Zahl der Fragment-Aufrufe noch den Lese-/Schreibbytes des Videospeichers oder GPU-Hardwarezählern. Metals Fast Clear, Attachment Load/Store und das Umschalten von Pässen bleiben unter der Kontrolle von GPU und Treiber.
Das erklärt auch, warum leere Bereiche flüssig blieben. Visibility Culling überspringt Striche außerhalb des Viewports;
Eine falsche Antwort bei 0.85 ms
Die App hatte bereits eine vorab erzeugte Ink-Textur für die ganze Seite mit zwei Pixeln pro Punkt. Wir versuchten, diese Textur während Schreiben, Verschieben und Zoomen direkt anzuzeigen und nur den aktuellen Apple-Pencil-Strich als Live-Vektor zu behalten; nach dem Ende der Interaktion sollte ein zusätzlicher Frame das hochauflösende Vektorergebnis neu rendern.
Dieser Ansatz war sehr schnell. Im selben dichten Bereich bei 300% Zoom sank die GPU-Zeit auf 0.84–0.85 ms und wuchs nicht mehr mit der Zahl vorhandener Kreidestriche.
Das Problem auf dem echten Gerät war ebenso deutlich. Bei 300% Zoom wurden ungefähr sechs Bildschirmpixel pro Punkt benötigt, der Cache lieferte aber nur zwei. Sobald der Apple Pencil die Oberfläche berührte, wurden alle vorhandenen Striche zu einem weichen Bild mit niedriger Auflösung; beim Abheben des Apple Pencil sprangen sie zurück zu voller Klarheit.
Der Tester sagte nur einen Satz: „Beim Schreiben wird die ganze Zeichenfläche unscharf. Sobald ich loslasse, wird sie wieder klar.“
Wir entfernten die Optimierung. 0.85 ms war das niedrigste gemessene Ergebnis, aber kein akzeptables Kreidewerkzeug. Vorhandene Striche sind Teil der Rückmeldung beim Schreiben; ihre Klarheit darf sich beim Aufsetzen des Stifts nicht ändern.
Jeden Kreidestrich auf sein eigenes Rechteck begrenzen
Die umgesetzte Korrektur behielt Scratch und Komposition pro Strich bei und verringerte nur deren Pixelarbeitsumfang. Jeder Strich besaß bereits eine Canvas-Bounding-Box, die aus der Vereinigung aller Stempelradien abgeleitet war. Der Renderer transformiert diese Bounding-Box in die Drawable-Koordinaten des aktuellen Viewports und erweitert sie für den Antialiasing-Rand um zwei Pixel:
Dasselbe Scissor-Rechteck wird anschließend für drei Dinge verwendet: zum Leeren des Scratch, zum Zeichnen des Strichs und zum Zurückkompositieren des Ergebnisses auf die Hauptoberfläche.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
Dieselbe Logik wird auch beim Baking und beim partiellen Replay auf der 4096²-Ink-Textur verwendet, sodass die Anzeige bei hohem Zoom und die gesetzte Ink-Schicht kein unterschiedliches Kreideverhalten erzeugen.
Zwei Details werden leicht übersehen.
Erstens findet loadAction = .clear eines Render-Passes während der Attachment-Ladephase statt und wird nicht durch den Rasterization-Scissor eingeschränkt. Bei weiterer Verwendung würde es die gesamte Scratch-Textur weiterhin leeren. Der korrigierte Pass verwendet .dontCare und zeichnet anschließend ein clear_fragment innerhalb des Scissors. Dieses Rechteck wird danach vollständig beschrieben, und die Komposition liest nur dasselbe Rechteck; alte Attachment-Inhalte müssen daher nicht geladen werden.
Zweitens muss der äußere Scissor nach jeder abgeschlossenen Komposition eines Kreidestrichs wiederhergestellt werden. Fehlt diese Zeile zur Zustandswiederherstellung, werden nachfolgende Stifte, Bilder oder Auswahlen weiterhin durch die Grenzen des vorherigen Kreidestrichs beschnitten und wirken wie fehlende Striche oder Bilder.
Das Kreidekorn wird weiterhin aus absoluten Canvas-Koordinaten statt aus lokalen UVs innerhalb des Rechtecks gesampelt. Das Verschieben des Scissors ändert nur, welche Pixel die GPU verarbeitet; es ändert nicht, welche Position der Grain-Textur ein Pixel liest. Benachbarte Rechtecke erzeugen daher keine Textur-Nähte, und beim Ziehen der Zeichenfläche driftet das Korn nicht.
Betrachtet man nur die Pixelarbeit, liegt der neue Arbeitsumfang nahe bei
wobei
Warum gleichfarbige Kreidestriche nicht gebatcht wurden
Die meisten Kreidestriche auf der Seite haben dieselbe Farbe und Dichte, und es liegt nahe, Dutzende Striche gemeinsam in den Scratch zu zeichnen und nur einmal zu kompositieren. Das würde die Zahl der Render-Pässe weiter verringern, aber die Farb- und Grain-Semantik in Überlappungsbereichen verändern.
Betrachten wir einen bewusst vereinfachten Fall: Zwei Striche teilen sich in einem bestimmten Pixel denselben Grain-Gate-Wert
während das Zusammenführen der Körper mit anschließender Anwendung eines einzelnen Gates den folgenden Ausdruck liefert:
Der Unterschied ist
Exaktes Batching erfordert den Nachweis, dass die Strichpixel disjunkt sind, oder eine unabhängige Atlas-Region für jeden Strich und eine Komposition in der ursprünglichen Reihenfolge. Der Abnahmetest auf dem Gerät behielt den Scissor-Ansatz bei, daher führte diese Runde weder ein Atlas noch dessen Verwaltungskomplexität ein.
Von einer Milliarde Pixeln zurück zu wenigen Millionen
Die Gerätemessungen:
| Szenario | Vor der Korrektur | Präziser Scissor |
|---|---|---|
| 70 sichtbare Kreidestriche, 300% Zoom, Schreiben | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 sichtbare Kreidestriche, 300% Zoom | GPU 77–80 ms | 13.7–15.6 ms |
| Theoretischer Rechteckumfang pro Frame (Scratch + Composite) | 783 M–1.34 B Pixel | ≈ 1.7 M–3 M Pixel |
Wir überprüften die Clipping-Grenzen außerdem mit 3,452 Strichen und 202,710 Samplepunkten aus einem Gerätedokument. Bei 0.5×, 1×, 2×, 3×, 5× und 8× Zoom wurden 186,408 Viewport-Fälle erzeugt; jedes Point Sprite, das eine Abdeckung ungleich null erzeugen konnte, lag innerhalb des berechneten Scissors. Die Prüfung umfasste Canvas-Ränder, Viewport-Ränder und verschiedene Kombinationen von Offsets.
Die gewählte Implementierung schaltet nicht abhängig vom Interaktionszustand auf ein LOD mit niedriger Auflösung um. Bei niedrigen Zoomstufen wird weiterhin die Ink-Textur der ganzen Seite angezeigt; bei hohen Zoomstufen werden sichtbare Striche weiterhin als Vektoren neu gezeichnet. Auf derselben Seite der Schwelle ersetzen Aufsetzen des Stifts und Verschieben vorhandene Striche nicht durch eine andere Klarheitsstufe. Beim Vektor-Redraw mit hohem Zoom decken Scratch-Clear und Composite jedes Kreidestrichs nur dessen eigene Bildschirm-Bounding-Box ab.
GPU-Zeit erfasste die Klarheit nicht
GPU-Performanceprobleme skalieren nicht unbedingt mit der sichtbarsten Größe in einer Datenstruktur. In diesem Fall waren die 3,571 Eingabepunkte ein naheliegender Verdächtiger; die Frame-Zeit bestimmten jedoch die Vollbildarbeit, die jeder der 70 Kreidestriche auslöste, und das Umschalten der Render-Pässe.
Auch die visuellen Eigenschaften schränkten die verfügbaren Optimierungen ein. Scratch pro Strich, die ursprüngliche Kompositionsreihenfolge und absolute Canvas-Koordinaten für das Grain konnten nicht beliebig entfernt werden. Gleiche Farbe und gleiche Dichte bedeuten nur, dass die Parameter übereinstimmen — sie beweisen nicht, dass sich überlappende Ergebnisse zusammenführen lassen.
Das Feedback vom echten Gerät verwarf die Version mit der niedrigsten GPU-Zeit. Die Bemerkung „Die ganze Zeichenfläche wird unscharf“ lieferte die Produktbedingung, die eine Messung allein nicht ausgedrückt hatte: Wenn der Apple Pencil die Oberfläche berührt, beobachten Nutzer auch die vorhandenen Striche.
Die gewählte Implementierung führt keine neue Interaktions-Cache-Schicht ein und reduziert die Klarheit nicht. Der Vektor-Redraw bei hohem Zoom begrenzt die Arbeit jedes Kreidestrichs einfach auf seine eigene Bildschirm-Bounding-Box. Nachdem die Version erneut auf LucasPad geladen worden war, lautete das Feedback: „Sieht großartig aus.“