Lulucat

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

Gaoge ZhangGaoge Zhang

La herramienta de tiza de Lulucat Notes perdía velocidad en las zonas de escritura densa. El cuello de botella no estaba en las 3,571 muestras de entrada, sino en las 70 pasadas scratch de pantalla completa que se hacían en cada fotograma. Una caché de baja resolución descartada y un rectángulo scissor para cada trazo completan la historia.

Vista recortada de Lulucat Notes en un iPad al 255% de zoom, con escritura de tiza roja y azul de un pasaje de caligrafía china clásica y parte de la barra de herramientas de la aplicación a la vista.

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

La herramienta de tiza de Lulucat Notes tenía un problema de rendimiento muy concreto: escribir en una zona en blanco resultaba fluido, pero al pasar a una zona que ya contenía trazos de tiza, la punta del lápiz empezaba a quedarse atrás. Continuar escribiendo allí también ralentizaba gradualmente el paneo del lienzo.

Bastaba una página de escritura corriente para provocarlo: un zoom del 300%, 70 trazos de tiza visibles en la zona local y un total de 3,571 puntos de muestreo de entrada. Las zonas vacías seguían funcionando con fluidez; solo se ralentizaba el área en la que se concentraban los trazos.

Tras el arreglo, la misma página puede seguir recibiendo escritura nueva con un zoom del 255%, y los trazos existentes mantienen toda su nitidez tanto al posar el lápiz como al hacer paneo.

Lulucat Notes en un iPad al 255% de zoom, mostrando escritura de tiza roja y azul. El texto dice "天行健,君子以自强不息;地势坤,君子以厚德载物" — un pasaje clásico chino. En la esquina superior derecha aparece una mascota azul de Lulucat. La barra de herramientas inferior muestra 155 trazos, Guardar, Borrar y un control deslizante de zoom al 255%.

La captura final del dispositivo, con 155 trazos en total. A este nivel de zoom, la nitidez de los trazos existentes no cambia temporalmente ni al posar el lápiz ni durante el paneo.

Por qué la tiza necesita una textura scratch

Un bolígrafo normal puede componer cada sello circular directamente sobre la textura de tinta usando una mezcla source-over. La tiza incorpora una capa de control del grano: el renderizador acumula primero la cobertura y la profundidad del cuerpo de un trazo completo, utiliza después una textura de grano fija para decidir qué posiciones reciben polvo de tiza y, por último, compone el resultado sobre la tinta existente.

Esta textura scratch aísla un único trazo de tiza. Es importante aislarlo porque los sellos del mismo trazo se solapan mucho; si cada sello aplicara el control de grano por separado, la línea central acumularía color repetido y los poros de la tiza variarían con la densidad de muestreo.

Con zoom alto, Lulucat Notes redibuja los trazos vectoriales visibles en el viewport actual. La implementación antigua ejecutaba estas operaciones para cada trazo de tiza visible:

  1. Finalizar el codificador de renderizado principal;
  2. Limpiar la textura scratch;
  3. Dibujar ese único trazo de tiza en la scratch;
  4. Abrir de nuevo el codificador de renderizado principal;
  5. Componer la scratch de vuelta sobre el drawable con un triángulo a pantalla completa.

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

Sea el número de trazos de tiza visibles y el número de píxeles del drawable. Si solo contamos el trabajo que escala con la cobertura de píxeles, la implementación antigua se aproximaba a

Cada trazo de tiza llevaba además una sobrecarga fija de pasada de renderizado, por lo que ese coste también crecía linealmente con . Los 3,571 puntos de entrada solo aportaban un coste secundario. Lo que crecía con el número local de trazos era el trabajo a pantalla completa que disparaba 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 llamaremos LucasPad. Los rangos siguientes son fluctuaciones habituales en registros de varios fotogramas, no compromisos de frecuencia de imagen para una versión distribuida. Con 70 trazos de tiza visibles, un fotograma solía requerir 52–60 ms de tiempo de GPU; en una zona con aproximadamente 120 trazos, el tiempo de GPU subía a 77–80 ms.

Si se estima mediante el área de los rectángulos a pantalla completa de las pasadas scratch y composite, el alcance teórico del trabajo 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 las áreas de los rectángulos y no equivale al número de invocaciones de fragmentos, a los bytes leídos o escritos en la memoria de vídeo ni a los contadores del hardware de la GPU. El fast clear de Metal, la carga y el almacenamiento de los attachments y el cambio de pasada siguen bajo el control de la GPU y del controlador.

Esto explica también por qué las zonas vacías seguían siendo fluidas. El descarte por visibilidad ignora los trazos que quedan fuera del viewport; se acerca a cero en una zona vacía, mientras que en una zona densa sigue aumentando.

Una respuesta equivocada a 0.85 ms

La aplicación ya tenía una textura de tinta de página completa horneada a dos píxeles por punto. Probamos a mostrar esa textura directamente durante la escritura, el paneo y el zoom, dejando únicamente el trazo actual del Apple Pencil como vector en vivo; al terminar la interacción, un fotograma adicional volvería a renderizar el resultado vectorial de alta resolución.

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

El problema en el dispositivo real se veía con la misma claridad. Con un zoom del 300% hacían falta aproximadamente seis píxeles de pantalla por punto, pero la caché solo ofrecía dos. En cuanto 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 la nitidez completa.

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

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

Limitar cada trazo de tiza a su propio rectángulo

La solución final mantuvo el scratch y la composición por trazo, y redujo únicamente 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 ese cuadro delimitador a las coordenadas drawable del viewport actual y lo amplía en dos píxeles para dejar margen al antialiasing:

Después se emplea el mismo rectángulo scissor para tres operaciones: limpiar la scratch, dibujar el trazo y componer el resultado de vuelta sobre 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 se usa para hornear y para la reproducción parcial en la textura de tinta 4096², de modo que la visualización con zoom alto y la capa de tinta asentada no producen dos comportamientos de tiza diferentes.

Aquí hay dos detalles fáciles de pasar por alto.

Primero, loadAction = .clear de un render pass tiene lugar durante la fase de carga del attachment y no queda limitado por el scissor de rasterización. Si se siguiera utilizando, todavía limpiaría toda la textura scratch. La pasada corregida usa .dontCare y dibuja después un clear_fragment dentro del scissor. El rectángulo se escribe por completo y la composición solo lee ese mismo rectángulo, así que no es necesario cargar el contenido anterior del attachment.

Segundo, al terminar la composición de cada trazo de tiza hay que restaurar el scissor exterior. Si se omite esta 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 que faltan.

El grano de la tiza continúa muestreándose desde coordenadas absolutas del lienzo, no desde UV locales dentro del rectángulo. Mover el scissor solo modifica los píxeles que procesa la GPU; no modifica la ubicación de la textura de grano que lee cada píxel. Por tanto, los rectángulos contiguos no crean costuras de textura y arrastrar el lienzo no desplaza el grano.

Si solo contamos la carga de píxeles, el nuevo alcance del trabajo se aproxima 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 bajado, pero ahora cada limpieza y cada composición quedan limitadas por el cuadro delimitador del trazo en pantalla.

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 color y densidad, así que resulta tentador dibujar docenas de trazos en la scratch de una sola vez y componerlos una sola vez. Eso reduciría todavía más las pasadas de renderizado, pero cambiaría la semántica del color y del grano en las zonas donde se solapan.

Veamos un caso deliberadamente simplificado: dos trazos comparten exactamente el mismo valor de control de grano en un píxel concreto, con coberturas del 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 luego un único control produce

La diferencia es . Cuando dos trazos se solapan y el control de grano no es ni cero puro ni uno puro, los resultados son distintos. Agruparlos directamente alteraría cómo se deposita el polvo de tiza en las intersecciones.

Para agruparlos exactamente habría que demostrar que los píxeles de los trazos son mutuamente disjuntos, o reservar una región independiente de atlas para cada trazo y componerlos en el orden original. La aceptación en el dispositivo final mantuvo el enfoque de scissor, así que esta iteración no añadió un atlas ni la complejidad de gestionarlo.

De mil millones de píxeles a unos pocos millones

Estas fueron las mediciones finales en el dispositivo:

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

También comprobamos los límites de recorte con 3,452 trazos y 202,710 puntos de muestreo procedentes de un documento del dispositivo. Con zoom de 0.5×, 1×, 2×, 3×, 5× y 8× se generaron 186,408 casos de viewport; todos los point sprites que podían producir una cobertura distinta de cero quedaron dentro del scissor calculado. La comprobación incluyó 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 y los niveles altos siguen redibujando como vectores los trazos visibles. En el mismo lado del umbral, posar el lápiz y hacer paneo no sustituyen 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 solo cubren su propio cuadro delimitador en pantalla.

El tiempo de GPU no capturó la nitidez

Los problemas de rendimiento de la GPU no tienen por qué escalar con la cantidad más visible de una estructura de datos. En este caso, los 3,571 puntos de entrada eran un sospechoso evidente; lo que fijaba el tiempo de fotograma era el trabajo a pantalla completa que provocaba 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. No se podían eliminar sin más el scratch por trazo, el orden de composición original ni las coordenadas absolutas del grano en el lienzo. Que el color y la densidad sean iguales solo significa que coinciden los parámetros; no demuestra que los resultados solapados puedan combinarse.

Las pruebas en un dispositivo real descartaron la versión con el menor tiempo de GPU. La frase «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 que ya existen.

La implementación que se probó al final no introduce una nueva capa de caché de interacción ni reduce la nitidez. El redibujado vectorial con zoom alto se limita a restringir el trabajo de cada trazo de tiza a su propio cuadro delimitador en pantalla. Tras cargarla de nuevo en LucasPad, la respuesta pasó a ser: «Tiene muy buena pinta.»