Performances de l’outil craie : des passes plein écran aux rectangles scissor
L’outil craie de Lulucat Notes ralentissait dans les zones où l’écriture était dense. Le goulot d’étranglement n’était pas constitué par les 3,571 échantillons d’entrée, mais par 70 passes scratch plein écran à chaque image. Une cache basse résolution abandonnée et un rectangle scissor par trait expliquent le reste.

Craie rouge et bleue dans le build final sur l’appareil, 155 traits à 255% de zoom.
L’outil craie de Lulucat Notes avait un problème de performance très précis : écrire dans une zone vierge restait fluide, mais dès qu’on passait dans une zone déjà couverte de traits de craie, la pointe du stylet se mettait à retarder. Continuer à écrire au même endroit ralentissait également peu à peu le déplacement du canevas.
Une seule page d’écriture courante suffisait à le déclencher : zoom à 300%, 70 traits de craie visibles dans la zone locale et 3,571 points d’échantillonnage d’entrée au total. Les zones vierges restaient fluides; seule la zone concentrant ces traits devenait lente.
Après le correctif, la même page peut continuer à recevoir de nouveaux traits avec un zoom de 255%, et les traits existants conservent toute leur netteté, que le stylet soit posé ou que le canevas soit déplacé.

La capture finale sur l’appareil, 155 traits au total. À ce niveau de zoom, la netteté des traits existants ne bascule temporairement ni au moment où le stylet est posé ni pendant le déplacement.
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 entier, puis utilise une texture de grain fixe pour choisir les positions qui reçoivent de la poussière de craie, avant de composer le résultat sur l’encre déjà présente.
Cette texture scratch isole un trait de craie. C’est important, car les tampons d’un même trait se recouvrent largement; si chaque tampon était contrôlé par le grain séparément, la ligne centrale du trait accumulerait plusieurs fois la couleur et les pores de la craie se déplaceraient selon la densité d’échantillonnage.
À fort zoom, Lulucat Notes redessine les traits vectoriels visibles dans le viewport courant. L’ancienne version exécutait les opérations suivantes pour chaque trait de craie visible :
- Terminer l’encodeur de rendu principal;
- Effacer la texture scratch;
- Dessiner ce trait de craie seul 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 mesurait 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 ajoutait aussi une surcharge fixe de passe de rendu; cette dépense augmentait donc elle aussi linéairement avec
Les mesures ont été faites 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 la modification, à l’aide des 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 indiquées sont les variations typiques observées dans des journaux de plusieurs images, pas une promesse de fréquence d’images pour la version distribuée. À 70 traits de craie visibles, une image demandait généralement 52–60 ms de temps GPU; dans une zone d’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. Il s’agit de la somme des surfaces des rectangles, qui n’est équivalente ni au nombre d’invocations de fragments, ni aux octets de lecture/écriture de la mémoire vidéo, ni aux compteurs matériels du GPU. Le fast clear de Metal, le chargement et l’enregistrement des attachments et le changement de passe restent contrôlés par le GPU et le pilote.
Cela explique aussi pourquoi les zones vierges restaient fluides. Le filtrage de visibilité ignore les traits situés hors du viewport;
Une mauvaise réponse à 0.85 ms
L’application disposait déjà d’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 ne conservant que le trait courant de l’Apple Pencil comme vecteur en direct; à la fin de l’interaction, une image supplémentaire aurait ensuite rendu le résultat vectoriel haute résolution.
Cette solution donnait d’excellentes performances. Dans la même zone dense à 300% de zoom, le temps GPU descendait à 0.84–0.85 ms et ne dépendait plus du nombre de traits de craie existants.
Sur l’appareil réel, le défaut était tout aussi évident. À 300% de zoom, il fallait environ six pixels d’écran par point, alors que la cache n’en offrait que deux. Dès que l’Apple Pencil touchait l’écran, tous les traits existants devenaient une image floue de basse résolution; quand le Pencil se relevait, ils retrouvaient instantanément leur netteté.
Le testeur a résumé le problème ainsi : « Quand j’écris, tout le canevas devient flou. Il redevient net dès que je lève le stylet. »
Nous avons supprimé l’optimisation. 0.85 ms était le meilleur résultat mesuré, mais il ne donnait pas un outil craie acceptable. Les traits existants font partie du retour fourni pendant l’écriture; leur netteté ne peut pas changer lorsque le stylet se pose.
Limiter chaque trait de craie à son propre rectangle
Le correctif final a conservé la scratch par trait et la composition par trait, tout en réduisant uniquement la quantité de pixels traités. Chaque trait possédait déjà une boîte englobante dans le canevas, dérivée de l’union de tous les rayons de ses tampons. Le moteur de rendu transforme cette boîte dans les coordonnées drawable du viewport courant et ajoute deux pixels de marge pour l’antialiasing :
Ce même rectangle scissor sert ensuite à 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 à la cuisson et à la relecture partielle de la texture d’encre 4096² : l’affichage à fort zoom et la couche d’encre stabilisée ne donnent donc pas deux comportements de craie différents.
Deux points sont faciles à manquer.
D’abord, le loadAction = .clear d’un render pass intervient pendant le chargement de l’attachment et n’est pas contraint par le scissor de rastérisation. Le conserver continuerait à effacer toute la texture scratch. La passe corrigée utilise .dontCare, puis dessine un clear_fragment dans le scissor. Le rectangle est ensuite entièrement écrit 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.
Ensuite, une fois la composition de chaque trait de craie terminée, il faut restaurer le scissor extérieur. Si cette restauration d’état est oubliée, les stylos, les images ou les sélections suivants resteront découpés selon les limites du trait de craie précédent et donneront l’impression de traits ou d’images manquants.
Le grain de la craie continue d’être échantillonné à partir des coordonnées absolues du canevas, plutôt qu’à partir d’UV locales dans le rectangle. Déplacer le scissor ne change que les pixels traités par le GPU, pas l’emplacement de la texture de grain lu par chaque pixel. Les rectangles voisins ne créent donc pas de joint de texture, et déplacer le canevas ne fait pas dériver le grain.
Si l’on ne considère 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 de la page partagent couleur et densité, et l’idée de dessiner des dizaines de traits à la fois dans la scratch pour ne composer qu’une seule fois est tentante. Cela diminuerait encore le nombre de passes, mais modifierait la sémantique des couleurs et du grain dans les zones de recouvrement.
Prenons un exemple délibérément simplifié : deux traits partagent exactement la même valeur de contrôle du grain
alors que la fusion préalable des corps suivie d’un seul contrôle donne
La différence vaut
Un regroupement exact demande de prouver que les pixels des traits sont mutuellement disjoints, ou d’attribuer à chaque trait une région indépendante d’un atlas et de composer dans l’ordre d’origine. L’acceptation finale sur l’appareil a conservé l’approche scissor; cette itération n’a donc ajouté ni atlas ni la complexité liée à sa gestion.
Du milliard de pixels à quelques millions
Mesures finales sur l’appareil :
| Scénario | Avant le correctif | 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 également 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×, nous avons généré 186,408 cas de viewport; chaque point sprite pouvant produire une couverture non nulle se trouvait dans le scissor calculé. Ce contrôle couvrait les bords du canevas, ceux du viewport et diverses combinaisons d’offsets.
Le code final ne passe pas à un LOD basse résolution selon l’état de l’interaction. Les faibles zooms affichent toujours la texture d’encre pleine page; les forts zooms redessinent toujours les traits visibles sous forme de vecteurs. Du même côté du seuil, poser le stylet ou déplacer le canevas ne remplace pas les traits existants par un autre niveau de netteté. Lors du 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 mesurait pas la netteté
Les problèmes de performance GPU ne suivent pas nécessairement la quantité la plus visible dans une structure de données. Ici, les 3,571 points d’entrée semblaient être le suspect évident; ce qui fixait le temps d’image, c’était le travail plein écran déclenché par chacun des 70 traits de craie, ainsi que les changements de passe de rendu correspondants.
La sémantique visuelle contraignait aussi les optimisations possibles. La scratch par trait, l’ordre de composition d’origine et les coordonnées absolues du grain dans le canevas ne pouvaient pas être supprimés à volonté. Une couleur et une densité identiques signifient seulement que les paramètres concordent; elles ne prouvent pas que des résultats superposés puissent être fusionnés.
Les essais sur un appareil réel ont rejeté la version au temps GPU le plus faible. La phrase « tout le canevas devient flou » a fourni la contrainte de produit que la mesure par elle-même n’avait pas exprimée : lorsque l’Apple Pencil touche l’écran, les utilisateurs observent aussi les traits qui existent déjà.
L’implémentation retenue au terme des essais n’ajoute pas de nouvelle couche de cache d’interaction et ne diminue pas la netteté. Le redessin vectoriel à fort zoom se limite à restreindre le travail de chaque trait de craie à sa propre boîte englobante à l’écran. Après l’avoir rechargée sur LucasPad, la réponse est devenue : « Ça a l’air super. »