Lulucat

Rendiment de l'eina de guix: de les passades a pantalla completa als rectangles de retall

Gaoge ZhangGaoge Zhang

L'eina de guix de Lulucat Notes s'alentia en zones d'escriptura denses. El coll d'ampolla no era les 3,571 mostres d'entrada, sinó 70 passades de scratch a pantalla completa per fotograma. Una memòria cau de baixa resolució rebutjada i un rectangle de retall per traç expliquen la resta.

Vista retallada de Lulucat Notes en un iPad amb un zoom del 255%, que mostra escriptura xinesa clàssica amb guix vermell i blau, amb parts de la barra d'eines de l'aplicació visibles.

Guix vermell i blau a la compilació de prova del dispositiu, 155 traços amb un zoom del 255%.

L’eina de guix de Lulucat Notes tenia un problema de rendiment molt concret: escriure en una zona buida semblava fluid, però en entrar en una zona ja plena de traços de guix la punta del llapis es quedava enrere. Continuar escrivint en aquella mateixa zona també alentia gradualment el desplaçament del llenç.

N’hi havia prou amb una pàgina d’escriptura normal per activar el problema: zoom del 300%, 70 traços de guix visibles a la zona local i 3,571 punts de mostra d’entrada en total. Les zones buides continuaven sent fluides; només s’alentia la zona on es concentraven els traços.

Després de la correcció, la mateixa pàgina pot continuar rebent escriptura nova amb un zoom del 255%, i els traços existents conserven tota la seva nitidesa tant en prémer el llapis com en desplaçar el llenç.

Lulucat Notes en un iPad amb un zoom del 255%, mostrant escriptura amb guix vermell i blau. El text diu «天行健,君子以自强不息;地势坤,君子以厚德载物» — un passatge clàssic xinès. Una mascota blava de Lulucat se situa a la part superior dreta. La barra d'eines inferior mostra un recompte de 155 traços, Save, Clear i un control lliscant de zoom del 255%.

La captura de la prova al dispositiu, amb 155 traços en total. Amb aquest nivell de zoom, la nitidesa dels traços existents no canvia temporalment ni en prémer el llapis ni en desplaçar el llenç.

Per què el guix necessita una textura de scratch

Un llapis normal pot compondre cada segell circular directament sobre la textura de tinta amb una mescla source-over. El guix afegeix una capa de filtratge de gra: el renderitzador acumula primer la cobertura i la profunditat del cos d’un traç sencer, després utilitza una textura de gra fixa per determinar quines posicions reben pols de guix i, finalment, compon el resultat sobre la tinta existent.

Aquesta textura de scratch aïlla un sol traç de guix. L’aïllament és important perquè els segells d’un mateix traç se superposen molt; si cada segell passés individualment pel filtre de gra, la línia central acumularia color repetit i els porus del guix canviarien amb la densitat del mostreig.

Amb nivells de zoom alts, Lulucat Notes redibuixa els traços vectorials visibles a la finestra gràfica actual. La implementació antiga feia aquests passos per a cada traç de guix visible:

  1. Finalitzar l’encoder de renderització principal;
  2. Netejar la textura de scratch;
  3. Dibuixar aquest traç de guix a la scratch;
  4. Reobrir l’encoder de renderització principal;
  5. Compondre la scratch de nou sobre el drawable amb un triangle a pantalla completa.

La semàntica d’un sol traç era correcta, però l’abast de la feina era molt més gran. El drawable de l’iPad era de 2732×2048 — aproximadament 5.6 milions de píxels. Cada traç de guix activava una passada de scratch i una composició a pantalla completa. 70 traços de guix significaven aproximadament 141 encoders de renderització i 70 composicions a pantalla completa.

Siguin el nombre de traços de guix visibles i el nombre de píxels del drawable. Si considerem només la feina que escala amb la cobertura de píxels, la implementació antiga s’aproximava a

Cada traç de guix també comportava una sobrecàrrega fixa de passada de renderització, de manera que aquest cost també escalava linealment amb . Els 3,571 punts d’entrada només aportaven un cost secundari. El que escalava amb el nombre local de traços era l’abast del treball a pantalla completa activat per cada traç.

Les mesures es van prendre en un iPad Pro de 12.9 polzades (5a generació, M1) amb iPadOS 18.6.2. Vam comparar les marques de temps de la GPU de la mateixa finestra gràfica abans i després del canvi, utilitzant marques de temps del command buffer en la mateixa compilació de dispositiu Debug en aquest iPad concret — que anomenarem LucasPad a partir d’ara. Els intervals següents són fluctuacions habituals de registres de múltiples fotogrames, no compromisos de velocitat de fotogrames per a una versió destinada a distribuir-se. Amb 70 traços de guix visibles, un sol fotograma necessitava normalment 52–60 ms de temps de GPU; en una zona amb aproximadament 120 traços, el temps de GPU pujava a 77–80 ms.

Si estimem l’abast teòric de treball per fotograma a partir de l’àrea del rectangle a pantalla completa de les passades de scratch i de composició, va créixer d’aproximadament 783 milions de píxels a 1.34 mil milions de píxels. Aquesta xifra és la suma de les àrees dels rectangles i no equival al nombre d’invocacions de fragments, als bytes de lectura/escriptura de la memòria de vídeo ni als comptadors de maquinari de la GPU. El fast clear de Metal, la càrrega/emmagatzematge de les attachments i el canvi de passades continuen sota el control de la GPU i del controlador.

Això també explica per què les zones buides continuaven sent fluides. El visibility culling omet els traços que queden fora de la finestra gràfica; en una zona buida és proper a zero, mentre que en una zona densa continua augmentant.

Una resposta equivocada a 0.85 ms

L’aplicació ja tenia una textura de tinta de pàgina sencera precuinada a dos píxels per punt. Vam provar de mostrar aquesta textura directament durant l’escriptura, el desplaçament i el zoom, mantenint només el traç actual de l’Apple Pencil com a vector viu; quan acabava la interacció, un fotograma addicional tornava a renderitzar el resultat vectorial d’alta resolució.

Aquest enfocament rendia molt bé. A la mateixa zona densa amb un zoom del 300%, el temps de GPU baixava a 0.84–0.85 ms i ja no creixia amb el nombre de traços de guix existents.

El problema al dispositiu real era igual de clar. Amb un zoom del 300% calien aproximadament sis píxels de pantalla per punt, però la memòria cau només en proporcionava dos. Tan bon punt l’Apple Pencil tocava la superfície, tots els traços existents es convertien en una imatge suau i de baixa resolució; en aixecar el Pencil, recuperaven de cop tota la nitidesa.

La persona que ho va provar va dir una cosa: «Quan escric, tot el llenç es torna borrós. Es torna nítid quan aixeco el llapis.»

Vam eliminar l’optimització. 0.85 ms era el resultat mesurat més baix, però no era una eina de guix acceptable. Els traços existents formen part de la resposta visual de l’escriptura; la seva nitidesa no pot canviar en prémer el llapis.

Limitar cada traç de guix al seu propi rectangle

La correcció implementada va conservar el scratch per traç i la composició per traç, i només en va reduir l’abast de treball de píxels. Cada traç ja tenia una capsa contenidora del llenç derivada de la unió de tots els radis dels seus segells. El renderitzador transforma aquesta capsa en les coordenades del drawable de la finestra gràfica actual i hi afegeix dos píxels de marge per a l’antialiàsing:

El mateix rectangle de retall s’utilitza per a tres coses: netejar la scratch, dibuixar el traç i tornar a compondre’n el resultat sobre la superfície principal.

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

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

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

La mateixa lògica també s’utilitza per al precuinat i la reproducció parcial sobre la textura de tinta 4096², de manera que la visualització amb zoom alt i la capa de tinta consolidada no produeixen dos comportaments diferents del guix.

Aquí hi ha dos detalls fàcils de passar per alt.

Primer, loadAction = .clear d’una passada de renderització té lloc durant l’etapa de càrrega de l’attachment i no està limitat pel rectangle de retall de la rasterització. Continuar utilitzant-lo encara netejaria tota la textura de scratch. La passada corregida utilitza .dontCare i després dibuixa un clear_fragment dins del rectangle de retall. Aquest rectangle s’escriu completament a continuació i la composició només llegeix el mateix rectangle, de manera que no cal carregar el contingut antic de l’attachment.

Segon, després que acabi la composició de cada traç de guix, cal restaurar el rectangle de retall exterior. Si s’omet aquesta línia de restauració d’estat, els llapis, les imatges o les seleccions posteriors continuaran retallats pels límits del traç de guix anterior i semblaran traços o imatges que falten.

El gra del guix continua mostrejant coordenades absolutes del llenç, no UV locals dins del rectangle. Moure el rectangle de retall només canvia quins píxels processa la GPU; no canvia quina ubicació de la textura de gra llegeix cada píxel. Per tant, els rectangles adjacents no produeixen costures de textura i arrossegar el llenç no fa derivar el gra.

Si considerem només la càrrega de píxels, el nou abast de treball s’aproxima a

on és l’àrea de la capsa contenidora alineada amb els eixos del traç de guix a la pantalla actual. El nombre d’encoders de renderització no ha disminuït, però ara cada neteja i composició queda limitada per la capsa contenidora del traç a la pantalla.

Per què no vam agrupar els traços de guix del mateix color

La majoria dels traços de guix de la pàgina comparteixen color i densitat, i resulta temptador dibuixar desenes de traços a la scratch alhora i compondre’ls només una vegada. Això reduiria encara més les passades de renderització, però canviaria la semàntica del color i del gra a les zones superposades.

Considerem un cas deliberadament simplificat: dos traços comparteixen el mateix valor de filtre de gra en un píxel determinat, amb cobertures de cos i . En el shader real, el filtre també depèn de la profunditat de pressió de cada traç; aquest cas més senzill ja és suficient per demostrar que l’agrupació no és, en general, equivalent. La composició actual per traç produeix

mentre que fusionar primer els cossos i aplicar després un sol filtre produeix

La diferència és . Sempre que dos traços se superposen i el filtre de gra no és ni zero pur ni u pur, els resultats són diferents. Agrupar-los directament canviaria com cau la pols de guix als encreuaments.

Un agrupament exacte requereix demostrar que els píxels dels traços són mútuament disjunts, o reservar una regió independent de l’atles per a cada traç i compondre’ls en l’ordre original. La validació al dispositiu va conservar l’enfocament del rectangle de retall, de manera que aquesta ronda no va introduir cap atles ni la complexitat de gestionar-lo.

Dels mil milions de píxels a uns quants milions

Les mesures del dispositiu:

EscenariAbans de la correccióRetall precís
70 traços de guix visibles, zoom del 300%, escrivintGPU 52–60 ms≈ 9–10 ms
≈ 121 traços de guix visibles, zoom del 300%GPU 77–80 ms13.7–15.6 ms
Abast teòric dels rectangles per fotograma (scratch + composició)783 M–1.34 B píxels≈ 1.7 M–3 M píxels

També vam verificar els límits del retall amb 3,452 traços i 202,710 punts de mostra d’un document del dispositiu. Amb zooms de 0.5×, 1×, 2×, 3×, 5× i 8×, es van generar 186,408 casos de finestra gràfica; cada point sprite que podia produir cobertura no nul·la quedava dins del rectangle de retall calculat. Aquesta comprovació cobria les vores del llenç, les vores de la finestra gràfica i diverses combinacions de desplaçament.

La implementació seleccionada no canvia a un LOD de baixa resolució segons l’estat de la interacció. Els nivells de zoom baix continuen mostrant la textura de tinta de pàgina sencera; els nivells de zoom alt continuen redibuixant els traços visibles com a vectors. Al mateix costat del llindar, prémer el llapis i desplaçar el llenç no substitueixen els traços existents per un nivell de nitidesa diferent. Durant el redibuix vectorial amb zoom alt, la neteja i la composició de cada traç de guix només cobreixen la seva pròpia capsa contenidora a la pantalla.

El temps de GPU no va capturar la nitidesa

Els problemes de rendiment de la GPU no necessàriament escalen amb la quantitat més visible de l’estructura de dades. En aquest cas, els 3,571 punts d’entrada eren un sospitós fàcil; el que determinava el temps de fotograma era el treball a pantalla completa activat per cadascun dels 70 traços de guix, juntament amb el canvi de passades de renderització.

La semàntica visual també limitava les optimitzacions disponibles. No es podien eliminar a voluntat el scratch per traç, l’ordre original de composició ni les coordenades absolutes del gra del llenç. Que el color i la densitat siguin iguals només vol dir que els paràmetres coincideixen; no demostra que els resultats superposats es puguin fusionar.

La resposta del dispositiu real va rebutjar la versió amb el temps de GPU més baix. El comentari «tot el llenç es torna borrós» va aportar la restricció de producte que la mesura sola no havia expressat: quan l’Apple Pencil toca la superfície, els usuaris també estan observant els traços existents.

La implementació seleccionada no introdueix cap nova capa de memòria cau d’interacció ni redueix la nitidesa. El redibuix vectorial amb zoom alt simplement limita el treball de cada traç de guix a la seva pròpia capsa contenidora a la pantalla. Després de tornar-la a carregar a LucasPad, la resposta va ser: «Fa molt bona pinta.»