Lulucat

Desempenho da ferramenta de giz: de passagens em ecrã inteiro a retângulos scissor

Gaoge ZhangGaoge Zhang

A ferramenta de giz do Lulucat Notes ficava lenta em zonas com escrita densa. O estrangulamento não eram as 3,571 amostras de entrada — eram 70 passagens scratch em ecrã inteiro por frame. Uma cache de baixa resolução rejeitada e um retângulo scissor por traço completam a história.

Vista recortada do Lulucat Notes num iPad com zoom de 255%, mostrando escrita a giz vermelho e azul com uma passagem clássica chinesa e parte da barra de ferramentas da aplicação.

Giz vermelho e azul na versão final do dispositivo, 155 traços com zoom de 255%.

A ferramenta de giz do Lulucat Notes tinha um problema de desempenho específico: escrever numa zona vazia parecia fluido, mas, ao entrar numa zona já preenchida com traços de giz, a ponta da caneta ficava para trás. Continuar a escrever nessa mesma zona também tornava a deslocação do canvas progressivamente mais lenta.

Uma única página de escrita comum bastava para desencadear o problema: zoom de 300%, 70 traços de giz visíveis na zona local, num total de 3,571 pontos de amostra de entrada. As zonas vazias continuavam fluidas; apenas a zona onde os traços se concentravam ficava lenta.

Depois da correção, a mesma página pode continuar a receber escrita nova com zoom de 255%, e os traços existentes conservam toda a sua nitidez tanto ao pousar a caneta como durante a deslocação.

Lulucat Notes num iPad com zoom de 255%, a apresentar escrita a giz vermelho e azul. O texto diz “天行健,君子以自强不息;地势坤,君子以厚德载物” — uma passagem clássica chinesa. Um mascote azul da Lulucat aparece no canto superior direito. A barra inferior mostra uma contagem de 155 traços, Save, Clear e um controlo deslizante de zoom de 255%.

A captura final do dispositivo, com 155 traços no total. Neste nível de zoom, nem pousar a caneta nem deslocar o canvas altera temporariamente a nitidez dos traços existentes.

Porque é que o giz precisa de uma textura scratch

Uma caneta comum pode compor cada carimbo circular diretamente sobre a textura de tinta com mistura source-over. O giz acrescenta uma camada de controlo do grão: o renderizador começa por acumular a cobertura do corpo e a profundidade de um traço inteiro, usa depois uma textura de grão fixa para determinar que posições recebem pó de giz e, por fim, compõe o resultado sobre a tinta existente.

Esta textura scratch isola um único traço de giz. O isolamento é importante porque os carimbos dentro do mesmo traço se sobrepõem bastante; se cada carimbo fosse filtrado pelo grão individualmente, a linha central do traço acumularia cor repetida e os poros do giz mudariam com a densidade da amostragem.

Com níveis de zoom elevados, o Lulucat Notes volta a desenhar os traços vetoriais visíveis no viewport atual. A implementação antiga executava estes passos para cada traço de giz visível:

  1. Terminar o render encoder principal;
  2. Limpar a textura scratch;
  3. Desenhar este único traço de giz no scratch;
  4. Reabrir o render encoder principal;
  5. Compor o scratch novamente no drawable através de um triângulo em ecrã inteiro.

A semântica de um traço estava correta, mas o âmbito do trabalho era muito maior. O drawable do iPad tinha 2732×2048 — aproximadamente 5.6 million pixels. Cada traço de giz desencadeava uma passagem scratch e uma composição em ecrã inteiro. Setenta traços de giz significavam aproximadamente 141 render encoders e 70 composições em ecrã inteiro.

Seja o número de traços de giz visíveis e a contagem de pixels do drawable. Considerando apenas o trabalho que escala com a cobertura de pixels, a implementação antiga aproximava-se de

Cada traço de giz também tinha o custo fixo de um render pass, pelo que esse custo crescia linearmente com . Os 3,571 pontos de entrada contribuíam apenas com um custo secundário. O que escalava com o número local de traços era o âmbito do trabalho em ecrã inteiro desencadeado por cada traço.

As medições foram feitas num 12.9-inch iPad Pro (5th generation, M1) com iPadOS 18.6.2. Comparamos os timestamps de GPU do mesmo viewport antes e depois da alteração, usando timestamps de command buffer na mesma Debug device build neste iPad específico — designado abaixo por LucasPad. Os intervalos seguintes são flutuações típicas de logs com vários frames, não compromissos de taxa de frames de uma versão distribuída. Com 70 traços de giz visíveis, um frame precisava normalmente de 52–60 ms de tempo de GPU; numa zona com aproximadamente 120 traços, o tempo de GPU subia para 77–80 ms.

Estimando pela área dos retângulos em ecrã inteiro das passagens scratch e composite, o âmbito teórico do trabalho por frame crescia de aproximadamente 783 million pixels para 1.34 billion pixels. Este valor é a soma das áreas dos retângulos e não equivale à contagem de invocações de fragmentos, aos bytes de leitura/escrita da memória de vídeo ou aos contadores de hardware da GPU. O fast clear, o attachment load/store e a mudança de passagens do Metal continuam sob controlo da GPU e do controlador.

Isto também explica porque é que as zonas vazias continuavam fluidas. O visibility culling ignora os traços fora do viewport; numa zona vazia, fica perto de zero, enquanto numa zona densa continua a crescer.

Uma resposta errada a 0.85 ms

A aplicação já tinha uma textura de tinta da página inteira baked a dois pixels por ponto. Tentámos apresentar esta textura diretamente durante a escrita, a deslocação e o zoom, mantendo apenas o traço atual do Apple Pencil como vetor live; depois de a interação terminar, mais um frame voltaria a renderizar o resultado vetorial de alta resolução.

Esta abordagem tinha um desempenho muito bom. Na mesma zona densa com zoom de 300%, o tempo de GPU desceu para 0.84–0.85 ms e deixou de crescer com o número de traços de giz existentes.

O problema no dispositivo real era igualmente claro. Com zoom de 300%, eram necessários aproximadamente seis pixels de ecrã por ponto, mas a cache fornecia apenas dois. No momento em que o Apple Pencil tocava no ecrã, todos os traços existentes se transformavam numa imagem suave de baixa resolução; ao levantar o Pencil, voltavam à nitidez total.

O tester disse uma coisa: “Quando estou a escrever, todo o canvas fica desfocado. Fica nítido assim que levanto a caneta.”

A otimização foi removida. 0.85 ms foi o resultado medido mais baixo, mas não era uma ferramenta de giz aceitável. Os traços existentes fazem parte do feedback da escrita; a sua nitidez não pode mudar ao pousar a caneta.

Limitar cada traço de giz ao seu próprio retângulo

A correção final manteve o scratch por traço e a composição por traço, reduzindo apenas o âmbito do trabalho de pixels. Cada traço já tinha uma caixa delimitadora no canvas, derivada da união de todos os raios dos seus carimbos. O renderizador transforma esta caixa delimitadora nas coordenadas do drawable do viewport atual e acrescenta dois pixels de margem para antialiasing:

O mesmo retângulo scissor é usado para três tarefas: limpar o scratch, desenhar o traço e compor o resultado de volta na 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)

A mesma lógica é usada no baking e no partial replay da textura de tinta 4096², para que a apresentação com zoom elevado e a camada de tinta consolidada não produzam dois comportamentos diferentes de giz.

Há aqui dois pormenores fáceis de ignorar.

Primeiro, o loadAction = .clear de um render pass ocorre durante a fase de carregamento do attachment e não é limitado pelo scissor de rasterização. Continuar a usá-lo ainda limparia toda a textura scratch. A passagem fixa usa .dontCare e depois desenha um clear_fragment dentro do scissor. Este retângulo é escrito por inteiro em seguida, e o composite lê apenas o mesmo retângulo, pelo que não é necessário carregar os conteúdos antigos do attachment.

Segundo, depois de terminar a composição de cada traço de giz, é necessário restaurar o scissor exterior. Se esta linha de restauração de estado for omitida, as canetas, imagens ou seleções seguintes continuarão a ser recortadas pelos limites do traço de giz anterior, parecendo traços ou imagens em falta.

O grão do giz continua a ser amostrado a partir de coordenadas absolutas do canvas, não de UVs locais. Mover o scissor apenas altera os pixels que a GPU processa; não altera a posição da textura de grão que cada pixel lê. Assim, os retângulos adjacentes não produzem emendas na textura e arrastar o canvas não faz o grão deslizar.

Considerando apenas a carga de pixels, o novo âmbito de trabalho aproxima-se de

onde é a área da caixa delimitadora alinhada pelos eixos do -ésimo traço de giz no ecrã atual. O número de render encoders não diminuiu, mas cada limpeza e composição está agora limitada à caixa delimitadora do traço no ecrã.

Porque é que os traços de giz da mesma cor não foram agrupados

A maioria dos traços de giz da página partilha a mesma cor e densidade, e é tentador desenhar dezenas de traços no scratch de uma vez e compor apenas uma vez. Isso reduziria ainda mais as passagens de renderização, mas alteraria a semântica da cor e do grão nas zonas sobrepostas.

Considere-se um caso deliberadamente simplificado: dois traços partilham o mesmo valor de grain-gate num pixel, com coberturas de corpo e . No shader real, o gate também depende da profundidade da pressão de cada traço; este caso mais simples é suficiente para mostrar que o agrupamento não é, em geral, equivalente. A composição atual por traço produz

enquanto fundir primeiro os corpos e aplicar uma única gate produz

A diferença é . Sempre que dois traços se sobrepõem e a gate de grão não é nem zero puro nem um puro, os resultados diferem. Agrupar diretamente alteraria a forma como o pó de giz assenta nos cruzamentos.

O agrupamento exato exige provar que os pixels dos traços são mutuamente disjuntos ou atribuir uma região independente de atlas a cada traço e compor pela ordem original. A aceitação final no dispositivo manteve a abordagem scissor, pelo que esta ronda não introduziu um atlas nem a complexidade da sua gestão.

De mil milhões de pixels de volta a alguns milhões

Medições finais no dispositivo:

CenárioAntes da correçãoScissor preciso
70 traços de giz visíveis, zoom de 300%, escritaGPU 52–60 ms≈ 9–10 ms
≈ 121 traços de giz visíveis, zoom de 300%GPU 77–80 ms13.7–15.6 ms
Âmbito teórico dos retângulos por frame (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Verificámos também os limites de clipping usando 3,452 traços e 202,710 pontos de amostra de um documento do dispositivo. Com zoom de 0.5×, 1×, 2×, 3×, 5× e 8×, foram gerados 186,408 casos de viewport; cada point sprite que pudesse produzir cobertura diferente de zero ficava dentro do scissor calculado. Esta verificação cobriu as margens do canvas, as margens do viewport e várias combinações de offset.

O código final não muda para um LOD de baixa resolução com base no estado da interação. Os níveis de zoom baixos continuam a apresentar a textura de tinta da página inteira; os níveis de zoom elevados continuam a redesenhar como vetores os traços visíveis. No mesmo lado do limiar, pousar a caneta e deslocar o canvas não substituem os traços existentes por outro nível de nitidez. Durante o redesenho vetorial com zoom elevado, a limpeza e a composição scratch de cada traço de giz cobrem apenas a sua própria caixa delimitadora no ecrã.

O tempo de GPU não captou a nitidez

Os problemas de desempenho da GPU não têm necessariamente a mesma escala que a quantidade mais visível na estrutura de dados. Neste caso, os 3,571 pontos de entrada eram um suspeito óbvio; o tempo de frame era determinado pelo trabalho em ecrã inteiro desencadeado por cada um dos 70 traços de giz, juntamente com a mudança de render-pass.

A semântica visual também limitou as otimizações disponíveis. O scratch por traço, a ordem original de composição e as coordenadas absolutas do grão no canvas não podiam ser removidos arbitrariamente. A mesma cor e a mesma densidade significam apenas que os parâmetros coincidem — não provam que resultados sobrepostos possam ser fundidos.

O feedback do dispositivo real rejeitou a versão com o menor tempo de GPU. A observação “todo o canvas fica desfocado” forneceu a restrição de produto que a medição, por si só, não tinha exprimido: quando o Apple Pencil toca no ecrã, os utilizadores também estão a observar os traços existentes.

A versão final não introduz uma nova camada de cache de interação e não reduz a nitidez. O redesenho vetorial com zoom elevado limita simplesmente o trabalho de cada traço de giz à sua própria caixa delimitadora no ecrã. Depois de ser carregada novamente no LucasPad, a reação foi: “Está ótimo.”