Lulucat

Rendimiento de la herramienta de tiza: de las pasadas a pantalla completa a los rectángulos scissor

Gaoge ZhangGaoge Zhang

La herramienta de tiza de Lulucat Notes se ralentizaba en las zonas con escritura densa. El cuello de botella no eran las 3,571 muestras de entrada, sino 70 pasadas de scratch a pantalla completa por fotograma. Una caché de baja resolución descartada y un rectángulo scissor por trazo completan la historia.

Vista recortada de Lulucat Notes en iPad con un zoom de 255%, que muestra escritura con tiza roja y azul de un pasaje de caligrafía china clásica, con partes de la barra de herramientas de la app visibles.

Tiza roja y azul en la compilación final para el dispositivo, 155 trazos con un zoom de 255%.

La herramienta de tiza de Lulucat Notes tenía un problema de rendimiento muy específico: escribir en una zona vacía se sentía fluido, pero al entrar en una zona ya llena de trazos de tiza, la punta del lápiz empezaba a quedarse atrás. Seguir escribiendo en esa misma zona también hacía que el desplazamiento del lienzo se ralentizara poco a poco.

Una sola página de escritura normal bastaba para activarlo: zoom de 300%, 70 trazos de tiza visibles en la zona local, con un total de 3,571 puntos de muestreo de entrada. Las zonas vacías seguían siendo fluidas; solo se volvía lenta el área donde se concentraban esos trazos.

Con el correctivo, la misma página puede seguir recibiendo escritura nueva con un zoom de 255%, y los trazos existentes conservan toda su nitidez tanto al apoyar el lápiz como al desplazar el lienzo.

Lulucat Notes en un iPad con un zoom de 255%, mostrando escritura con tiza roja y azul. El texto dice "天行健,君子以自强不息;地势坤,君子以厚德载物" — un pasaje de la tradición clásica china. Un personaje azul de Lulucat aparece arriba a la derecha. La barra de herramientas inferior muestra un conteo de 155 trazos, Save, Clear y un control deslizante de zoom de 255%.

La captura final del dispositivo, 155 trazos en total. Con este nivel de zoom, ni al apoyar el lápiz ni al desplazar el lienzo la nitidez de los trazos existentes cambia temporalmente.

Por qué la tiza necesita una textura scratch

Una pluma normal puede componer cada sello circular directamente sobre la textura de tinta mediante una mezcla source-over. La tiza añade una capa de control del grano: primero, el renderizador acumula la cobertura y la profundidad del cuerpo de un trazo completo; después usa una textura de grano fija para determinar qué posiciones reciben polvo de tiza; por último, compone el resultado sobre la tinta existente.

Esta textura scratch aísla un solo trazo de tiza. El aislamiento importa porque los sellos dentro del mismo trazo se superponen mucho; si cada sello recibiera el control de grano por separado, la línea central del trazo acumularía color repetido y los poros de la tiza cambiarían con la densidad del muestreo.

Con niveles altos de zoom, Lulucat Notes vuelve a dibujar los trazos vectoriales visibles en el viewport actual. La implementación anterior hacía estos pasos para cada trazo de tiza visible:

  1. Terminar el codificador de renderizado principal;
  2. Limpiar la textura scratch;
  3. Dibujar este único trazo de tiza en la scratch;
  4. Volver a abrir el codificador de renderizado principal;
  5. Componer la scratch de vuelta en el drawable con un triángulo de pantalla completa.

El drawable del iPad era de 2732×2048 — aproximadamente 5.6 millones de píxeles. Cada trazo de tiza activaba una pasada scratch y una composición de pantalla completa. Setenta trazos de tiza significaban aproximadamente 141 codificadores de renderizado y 70 composiciones de pantalla completa.

Sea el número de trazos de tiza visibles y el número de píxeles del drawable. Si consideramos únicamente el trabajo que escala con la cobertura de píxeles, la implementación anterior se acercaba a

Cada trazo de tiza también llevaba un costo fijo de una pasada de renderizado, así que ese costo también crecía linealmente con . Los 3,571 puntos de entrada aportaban solo un costo secundario. Lo que escalaba con el número local de trazos era el trabajo de pantalla completa activado por cada uno.

Las mediciones se tomaron en un iPad Pro de 12.9 pulgadas (5.ª generación, M1) con iPadOS 18.6.2. Comparamos las marcas de tiempo de GPU del mismo viewport antes y después del cambio, usando marcas de tiempo del command buffer en la misma compilación de Debug para dispositivo en este iPad concreto — al que llamamos LucasPad a continuación. Los rangos siguientes son fluctuaciones habituales de registros de varios fotogramas, no compromisos de frecuencia de cuadros de una versión publicada. Con 70 trazos de tiza visibles, un solo fotograma normalmente necesitaba 52–60 ms de tiempo de GPU; en una zona con aproximadamente 120 trazos, el tiempo de GPU subía a 77–80 ms.

Al estimar mediante el área del rectángulo de pantalla completa de las pasadas scratch y composite, el alcance de trabajo teórico por fotograma crecía de aproximadamente 783 millones de píxeles a 1.34 mil millones de píxeles. Esta cifra es la suma de áreas rectangulares y no equivale a conteos de invocaciones de fragmentos, bytes de lectura/escritura de memoria de video ni contadores del hardware de GPU. El fast clear de Metal, la carga y el almacenamiento de los attachments y el cambio de pasadas siguen bajo el control de la GPU y del controlador.

Esto también explica por qué las zonas vacías seguían siendo fluidas. La eliminación por visibilidad omite los trazos que están fuera del viewport; en una zona vacía se acerca a cero, mientras que en una zona densa sigue creciendo.

Una respuesta equivocada a 0.85 ms

La app ya tenía una textura de tinta de página completa horneada a dos píxeles por punto. Probamos mostrar esta textura directamente durante la escritura, el desplazamiento y el zoom, manteniendo solo el trazo actual del Apple Pencil como vector en vivo; después de terminar la interacción, un fotograma adicional volvería a renderizar el resultado vectorial de alta resolución.

Este enfoque funcionaba muy bien. En la misma zona densa con un zoom de 300%, el tiempo de GPU bajó a 0.84–0.85 ms y dejó de crecer con el número de trazos de tiza existentes.

El problema en el dispositivo real era igual de claro. Con un zoom de 300% se necesitaban aproximadamente seis píxeles de pantalla por punto, pero la caché solo proporcionaba dos. En el momento en que el Apple Pencil tocaba la pantalla, todos los trazos existentes se convertían en una imagen suave y de baja resolución; al levantar el Pencil, recuperaban de golpe toda su nitidez.

La persona que hacía la prueba dijo una cosa: «Cuando estoy escribiendo, todo el lienzo se vuelve borroso. Se aclara en cuanto levanto el lápiz.»

Eliminamos la optimización. 0.85 ms era el resultado más bajo medido, pero no era una herramienta de tiza aceptable. Los trazos existentes forman parte de la respuesta de escritura; su nitidez no puede cambiar al apoyar el lápiz.

Limitar cada trazo de tiza a su propio rectángulo

El correctivo final conservó el scratch por trazo y la composición por trazo, y solo redujo el alcance de su trabajo de píxeles. Cada trazo ya tenía un cuadro delimitador del lienzo derivado de la unión de todos los radios de sus sellos. El renderizador transforma este cuadro delimitador a las coordenadas drawable del viewport actual y le añade dos píxeles de margen para el antialiasing:

El mismo rectángulo scissor se usa entonces para tres cosas: limpiar la scratch, dibujar el trazo y componer el resultado de vuelta en la superficie principal.

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

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

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

La misma lógica también se usa para hornear y reproducir parcialmente la textura de tinta 4096², así que la visualización con zoom alto y la capa de tinta asentada no producen dos comportamientos distintos para la tiza.

Aquí es fácil pasar por alto dos detalles.

Primero, el loadAction = .clear de un render pass ocurre durante la etapa de carga del attachment y no está limitado por el scissor de rasterización. Seguir usándolo todavía limpiaría toda la textura scratch. La pasada corregida usa .dontCare y después dibuja un clear_fragment dentro del scissor. Este rectángulo se escribe por completo y la composición solo lee ese mismo rectángulo, así que no hace falta cargar el contenido anterior del attachment.

Segundo, después de que termine la composición de cada trazo de tiza, hay que restaurar el scissor exterior. Si se omite esta línea de restauración de estado, los lápices, las imágenes o las selecciones posteriores seguirán recortados por los límites del trazo de tiza anterior y parecerán trazos o imágenes desaparecidos.

El grano de la tiza sigue muestreándose desde coordenadas absolutas del lienzo, no desde UV locales dentro del rectángulo. Mover el scissor solo cambia qué píxeles procesa la GPU; no cambia qué ubicación de la textura de grano lee cada píxel. Por eso los rectángulos contiguos no producen costuras de textura y arrastrar el lienzo no hace que el grano se desplace.

Si consideramos únicamente la carga de píxeles, el nuevo alcance de trabajo se acerca a

donde es el área del cuadro delimitador alineado con los ejes del -ésimo trazo de tiza en la pantalla actual. El número de codificadores de renderizado no ha disminuido, pero ahora cada limpieza y composición está limitada por el cuadro delimitador en pantalla del trazo.

Por qué no se agruparon los trazos de tiza del mismo color

La mayoría de los trazos de tiza de la página comparten el mismo color y densidad, y resulta tentador dibujar docenas de trazos en la scratch de una vez y componer solo una vez. Esto reduciría aún más las pasadas de renderizado, pero cambia la semántica del color y del grano en las zonas superpuestas.

Consideremos un caso deliberadamente simplificado: dos trazos comparten exactamente el mismo valor de control de grano en un píxel dado, con coberturas de cuerpo y . En el shader real, el control también depende de la profundidad de presión de cada trazo; este caso más sencillo basta para demostrar que el agrupamiento no es equivalente en general. La composición actual por trazo produce

mientras que fusionar primero los cuerpos y aplicar después un único control produce

La diferencia es . Siempre que dos trazos se superponen y el control de grano no sea ni cero puro ni uno puro, los resultados difieren. Agruparlos directamente cambiaría cómo cae el polvo de tiza en los cruces.

Para agruparlos exactamente hace falta demostrar que los píxeles de los trazos son mutuamente disjuntos, o asignar una región independiente de atlas a cada trazo y componerlos en el orden original. La aceptación en el dispositivo final conservó el enfoque de scissor, así que esta ronda no introdujo un atlas ni la complejidad de gestionarlo.

De mil millones de píxeles a unos pocos millones

Las mediciones finales en el dispositivo:

EscenarioAntes de la correcciónScissor preciso
70 trazos de tiza visibles, zoom de 300%, durante la escrituraGPU 52–60 ms≈ 9–10 ms
≈ 121 trazos de tiza visibles, zoom de 300%GPU 77–80 ms13.7–15.6 ms
Alcance teórico de rectángulos por fotograma (scratch + composite)783 M–1.34 B píxeles≈ 1.7 M–3 M píxeles

También verificamos los límites de recorte usando 3,452 trazos y 202,710 puntos de muestreo de un documento del dispositivo. Con zoom de 0.5×, 1×, 2×, 3×, 5× y 8× se generaron 186,408 casos de viewport; cada point sprite que podía producir una cobertura distinta de cero quedó dentro del scissor calculado. Esta comprobación cubrió los bordes del lienzo, los bordes del viewport y varias combinaciones de offsets.

El código final no cambia a un LOD de baja resolución según el estado de la interacción. Los niveles de zoom bajos siguen mostrando la textura de tinta de página completa; los niveles de zoom altos siguen volviendo a dibujar como vectores los trazos visibles. En el mismo lado del umbral, apoyar el lápiz y desplazar el lienzo no reemplazan los trazos existentes por otro nivel de nitidez. Durante el redibujado vectorial con zoom alto, la limpieza scratch y la composición de cada trazo de tiza cubren únicamente su propio cuadro delimitador en pantalla.

El tiempo de GPU no capturó la nitidez

Los problemas de rendimiento de GPU no necesariamente escalan con la cantidad más visible de la estructura de datos. En este caso, los 3,571 puntos de entrada eran un sospechoso fácil; lo que determinaba el tiempo de fotograma era el trabajo de pantalla completa activado por cada uno de los 70 trazos de tiza, junto con sus cambios de pasada de renderizado.

La semántica visual también limitaba las optimizaciones disponibles. El scratch por trazo, el orden de composición original y las coordenadas absolutas del grano en el lienzo no podían eliminarse a voluntad. Que el color y la densidad sean iguales solo significa que los parámetros coinciden: no demuestra que los resultados superpuestos puedan fusionarse.

La respuesta en un dispositivo real rechazó la versión con el menor tiempo de GPU. La observación «todo el lienzo se vuelve borroso» aportó la restricción de producto que la medición por sí sola no había expresado: cuando el Apple Pencil toca la pantalla, los usuarios también están observando los trazos existentes.

La implementación examinada al final no introduce una nueva capa de caché de interacción ni reduce la nitidez. El redibujado vectorial con zoom alto simplemente limita el trabajo de cada trazo de tiza a su propio cuadro delimitador en pantalla. Después de cargarla de nuevo en LucasPad, los comentarios pasaron a ser: «Se ve genial.»