Performance de l’outil de craie : des passes plein écran aux rectangles scissor
L’outil de craie de Lulucat Notes ralentissait dans les zones d’écriture dense. Le goulot d’étranglement n’était pas les 3,571 échantillons d’entrée, mais 70 passes scratch plein écran par image. Une cache basse résolution rejetée et un rectangle scissor par trait racontent la suite.

Craie rouge et bleue dans le build final sur l’appareil, 155 traits à 255% de zoom.
L’outil de craie de Lulucat Notes avait un problème de performance bien particulier : écrire dans une zone vide semblait fluide, mais dès qu’on entrait dans une zone déjà remplie de traits de craie, la pointe du crayon prenait du retard. Continuer à écrire dans cette même zone ralentissait aussi graduellement le déplacement du canevas.
Une seule page d’écriture ordinaire suffisait à déclencher le problème : zoom à 300%, 70 traits de craie visibles dans la zone locale, pour un total de 3,571 points d’échantillonnage d’entrée. Les zones vides restaient fluides; seule la zone où les traits étaient concentrés devenait lente.
Après le correctif, la même page peut continuer à recevoir de nouveaux traits à 255% de zoom, et les traits existants gardent toute leur netteté pendant que le crayon est posé comme pendant le déplacement du canevas.

La capture finale sur l’appareil, 155 traits au total. À ce niveau de zoom, la netteté des traits existants ne change temporairement ni quand le crayon est posé ni pendant le déplacement du canevas.
Pourquoi la craie a besoin d’une texture scratch
Un stylo ordinaire peut composer chaque tampon circulaire directement sur la texture d’encre avec un mélange source-over. La craie ajoute une couche de contrôle du grain : le moteur de rendu accumule d’abord la couverture et la profondeur du corps d’un trait complet, utilise ensuite une texture de grain fixe pour déterminer quelles positions reçoivent de la poussière de craie, puis compose le résultat sur l’encre existante.
Cette texture scratch isole un seul trait de craie. L’isolation compte parce que les tampons d’un même trait se chevauchent fortement; si chaque tampon était contrôlé par le grain individuellement, l’axe central du trait accumulerait de la couleur à répétition et les pores de la craie se déplaceraient avec la densité d’échantillonnage.
À des niveaux de zoom élevés, Lulucat Notes redessine les traits vectoriels visibles dans le viewport courant. L’ancienne implémentation effectuait les étapes suivantes pour chaque trait de craie visible :
- Terminer l’encodeur de rendu principal;
- Effacer la texture scratch;
- Dessiner ce seul trait de craie dans la scratch;
- Rouvrir l’encodeur de rendu principal;
- Composer la scratch sur le drawable avec un triangle plein écran.
Le drawable de l’iPad faisait 2732×2048 — environ 5.6 millions de pixels. Chaque trait de craie déclenchait une passe scratch et une composition plein écran. Soixante-dix traits de craie représentaient environ 141 encodeurs de rendu et 70 compositions plein écran.
Soit
Chaque trait de craie comportait aussi une surcharge fixe de passe de rendu, donc ce coût augmentait lui aussi linéairement avec
Les mesures ont été prises sur un iPad Pro de 12.9 pouces (5e génération, M1) sous iPadOS 18.6.2. Nous avons comparé les marques de temps GPU du même viewport avant et après le changement, avec les marques de temps du command buffer dans le même build Debug sur appareil, sur cet iPad précis — appelé LucasPad ci-dessous. Les plages ci-dessous sont des fluctuations typiques de journaux sur plusieurs images, et non des engagements de fréquence d’images pour une version livrée. Avec 70 traits de craie visibles, une image demandait généralement 52–60 ms de temps GPU; dans une zone comptant environ 120 traits, le temps GPU montait à 77–80 ms.
En estimant la surface des rectangles plein écran des passes scratch et composite, l’étendue théorique du travail par image passait d’environ 783 millions de pixels à 1.34 milliard de pixels. Ce chiffre est la somme des surfaces des rectangles et n’équivaut ni au nombre d’invocations de fragments, ni aux octets lus ou écrits en mémoire vidéo, ni aux compteurs matériels du GPU. Le fast clear de Metal, le chargement et l’enregistrement des attachments ainsi que le changement de passe restent sous le contrôle du GPU et du pilote.
Cela explique aussi pourquoi les zones vides restaient fluides. Le culling de visibilité ignore les traits situés à l’extérieur du viewport; dans une zone vide,
Une mauvaise réponse à 0.85 ms
L’application possédait déjà une texture d’encre pleine page cuite à deux pixels par point. Nous avons essayé d’afficher cette texture directement pendant l’écriture, le déplacement et le zoom, en gardant uniquement le trait courant de l’Apple Pencil comme vecteur vivant; à la fin de l’interaction, une image supplémentaire aurait rendu de nouveau le résultat vectoriel haute résolution.
Cette approche était très performante. Dans la même zone dense à 300% de zoom, le temps GPU est descendu à 0.84–0.85 ms et ne montait plus avec le nombre de traits de craie existants.
Le problème sur l’appareil réel était tout aussi évident. À 300% de zoom, il fallait environ six pixels d’écran par point, mais la cache n’en fournissait que deux. Dès que l’Apple Pencil touchait l’écran, tous les traits existants devenaient une image douce de basse résolution; lorsque le Pencil se relevait, ils revenaient d’un coup à leur pleine netteté.
La personne qui testait a dit une seule chose : « Quand j’écris, tout le canevas devient flou. Il redevient net dès que je lève le crayon. »
Nous avons retiré l’optimisation. 0.85 ms était le meilleur résultat mesuré, mais ce n’était pas un outil de craie acceptable. Les traits existants font partie du retour visuel de l’écriture; leur netteté ne peut pas changer au moment où le crayon se pose.
Limiter chaque trait de craie à son propre rectangle
La correction finale a conservé la scratch et la composition par trait, et a seulement réduit l’étendue de leur travail sur les pixels. Chaque trait avait déjà une boîte englobante du canevas dérivée de l’union de tous les rayons de ses tampons. Le moteur de rendu transforme cette boîte englobante dans les coordonnées drawable du viewport courant et lui ajoute deux pixels de marge pour l’antialiasing :
Le même rectangle scissor sert ensuite à trois choses : effacer la scratch, dessiner le trait et recomposer le résultat sur la surface principale.
let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)
scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)
mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)
La même logique sert aussi à cuire l’encre et à faire une reprise partielle sur la texture d’encre 4096²; l’affichage à fort zoom et la couche d’encre stabilisée ne produisent donc pas deux comportements différents pour la craie.
Deux détails sont faciles à manquer ici.
Premièrement, le loadAction = .clear d’un render pass se produit pendant l’étape de chargement de l’attachment et n’est pas limité par le scissor de rastérisation. Continuer à l’utiliser effacerait encore toute la texture scratch. La passe corrigée utilise .dontCare, puis dessine un clear_fragment dans le scissor. Ce rectangle est ensuite écrit au complet, et le composite ne lit que ce même rectangle; il n’est donc pas nécessaire de charger l’ancien contenu de l’attachment.
Deuxièmement, après la fin de la composition de chaque trait de craie, le scissor extérieur doit être restauré. Si cette ligne de restauration d’état est omise, les crayons, les images ou les sélections qui suivent continueront d’être découpés par les limites du trait de craie précédent et sembleront être des traits ou des images manquants.
Le grain de la craie est toujours échantillonné à partir de coordonnées absolues du canevas, et non d’UV locales dans le rectangle. Déplacer le scissor change seulement les pixels traités par le GPU; cela ne change pas l’emplacement de la texture de grain lu par chaque pixel. Les rectangles adjacents ne produisent donc aucune jointure de texture, et faire glisser le canevas ne fait pas dériver le grain.
En ne considérant que la charge de pixels, la nouvelle étendue de travail est proche de
où
Pourquoi les traits de craie de même couleur n’ont pas été regroupés
La plupart des traits de craie sur la page ont la même couleur et la même densité, et il est tentant d’en dessiner des dizaines dans la scratch en une seule fois pour ne faire qu’une composition. Cela réduirait encore les passes de rendu, mais changerait la sémantique de la couleur et du grain dans les zones qui se chevauchent.
Prenons un cas délibérément simplifié : deux traits partagent exactement la même valeur de contrôle du grain
alors que fusionner d’abord les corps puis appliquer un seul contrôle produit
La différence est
Un regroupement exact exige de prouver que les pixels des traits sont mutuellement disjoints, ou d’allouer une région d’atlas indépendante à chaque trait et de composer dans l’ordre original. L’acceptation finale sur l’appareil a conservé l’approche scissor; cette passe n’a donc introduit ni atlas ni la complexité de sa gestion.
Du milliard de pixels à quelques millions
Les mesures finales sur l’appareil :
| Scénario | Avant la correction | Scissor précis |
|---|---|---|
| 70 traits de craie visibles, zoom à 300%, écriture | GPU 52–60 ms | ≈ 9–10 ms |
| ≈ 121 traits de craie visibles, zoom à 300% | GPU 77–80 ms | 13.7–15.6 ms |
| Étendue théorique des rectangles par image (scratch + composite) | 783 M–1.34 B pixels | ≈ 1.7 M–3 M pixels |
Nous avons aussi vérifié les limites de découpage avec 3,452 traits et 202,710 points d’échantillonnage provenant d’un document sur l’appareil. À des zooms de 0.5×, 1×, 2×, 3×, 5× et 8×, 186,408 cas de viewport ont été générés; chaque point sprite susceptible de produire une couverture non nulle se trouvait dans le scissor calculé. Cette vérification couvrait les bords du canevas, ceux du viewport et plusieurs combinaisons d’offsets.
Le code final ne passe pas à un LOD basse résolution selon l’état de l’interaction. Les faibles niveaux de zoom affichent toujours la texture d’encre pleine page; les niveaux élevés redessinent toujours les traits visibles sous forme de vecteurs. Du même côté du seuil, poser le crayon et déplacer le canevas ne remplacent pas les traits existants par un autre niveau de netteté. Pendant le redessin vectoriel à fort zoom, l’effacement scratch et la composition de chaque trait de craie ne couvrent que sa propre boîte englobante à l’écran.
Le temps GPU ne capturait pas la netteté
Les problèmes de performance GPU ne varient pas forcément avec la quantité la plus visible dans la structure de données. Ici, les 3,571 points d’entrée étaient un suspect facile; ce qui déterminait le temps par image était le travail plein écran déclenché par chacun des 70 traits de craie, avec les changements de passe de rendu qui l’accompagnaient.
La sémantique visuelle limitait aussi les optimisations possibles. On ne pouvait pas supprimer à volonté la scratch par trait, l’ordre de composition original ni les coordonnées absolues du grain sur le canevas. Même couleur et même densité signifient seulement que les paramètres correspondent; cela ne prouve pas que les résultats qui se chevauchent puissent être fusionnés.
Le retour sur l’appareil réel a rejeté la version qui avait le temps GPU le plus bas. La remarque « tout le canevas devient flou » exprimait la contrainte produit que la mesure seule n’avait pas révélée : lorsque l’Apple Pencil touche l’écran, les utilisateurs observent aussi les traits existants.
L’implémentation évaluée au terme des essais n’ajoute aucune nouvelle couche de cache d’interaction et ne réduit pas la netteté. Le redessin vectoriel à fort zoom limite simplement le travail de chaque trait de craie à sa propre boîte englobante à l’écran. Après son rechargement sur LucasPad, le retour est devenu : « C’est super. »