Lulucat

Desempenho da ferramenta de giz: de passadas em tela cheia a retângulos scissor

Gaoge ZhangGaoge Zhang

A ferramenta de giz do Lulucat Notes ficava lenta em áreas com escrita densa. O gargalo não eram as 3,571 amostras de entrada — eram 70 passadas scratch em tela cheia por quadro. Um cache de baixa resolução rejeitado e um retângulo scissor por traço completam a história.

Visão recortada do Lulucat Notes no iPad com zoom de 255%, mostrando escrita em giz vermelho e azul com um trecho clássico chinês e parte da barra de ferramentas do aplicativo.

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 bem específico: escrever em uma área vazia parecia fluido, mas, ao entrar em uma área já preenchida com traços de giz, a ponta da caneta ficava para trás. Continuar escrevendo nessa mesma área também deixava o deslocamento da tela progressivamente mais lento.

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

Depois da correção, a mesma página pode continuar recebendo escrita nova com zoom de 255%, e os traços existentes mantêm toda a nitidez tanto durante o toque da caneta quanto durante o deslocamento.

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

A captura final do dispositivo, com 155 traços no total. Nesse nível de zoom, nem o toque da caneta nem o deslocamento altera temporariamente a nitidez dos traços existentes.

Por que o giz precisa de uma textura scratch

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

Essa textura scratch isola um único traço de giz. O isolamento importa porque os carimbos dentro do mesmo traço se sobrepõem bastante; se cada carimbo recebesse o controle de granulação individualmente, a linha central do traço acumularia cor repetida, e os poros do giz mudariam conforme a densidade de amostragem.

Em níveis altos de zoom, o Lulucat Notes redesenha 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. Encerrar o encoder de renderização principal;
  2. Limpar a textura scratch;
  3. Desenhar esse único traço de giz no scratch;
  4. Reabrir o encoder de renderização principal;
  5. Compor o scratch de volta no drawable com um triângulo em tela cheia.

A semântica de um traço estava correta, mas o escopo de trabalho era muito maior. O drawable do iPad tinha 2732×2048 — aproximadamente 5.6 million pixels. Cada traço de giz acionava uma passada scratch e uma composição em tela cheia. Setenta traços de giz significavam aproximadamente 141 encoders de renderização e 70 composições em tela cheia.

Seja o número de traços de giz visíveis e a quantidade de pixels do drawable. Considerando apenas o trabalho que escala com a cobertura de pixels, a implementação antiga ficava próxima de

Cada traço de giz também carregava um custo fixo de render pass, então esse custo também crescia linearmente com . Os 3,571 pontos de entrada contribuíam apenas com um custo secundário. O que escalava com a quantidade local de traços era o escopo de trabalho em tela cheia acionado por cada traço.

As medições foram feitas em um 12.9-inch iPad Pro (5th generation, M1) executando iPadOS 18.6.2. Comparamos os timestamps de GPU do mesmo viewport antes e depois da mudança, usando timestamps de command buffer no mesmo Debug device build neste iPad específico — chamado de LucasPad a seguir. Os intervalos abaixo são flutuações típicas de logs com vários quadros, não compromissos de taxa de quadros de uma versão distribuída. Com 70 traços de giz visíveis, um quadro normalmente exigia 52–60 ms de tempo de GPU; em uma área com aproximadamente 120 traços, o tempo de GPU subia para 77–80 ms.

Estimando pela área dos retângulos em tela cheia das passadas scratch e composite, o escopo teórico de trabalho por quadro crescia de aproximadamente 783 million pixels para 1.34 billion pixels. Esse número é 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 nem aos contadores de hardware da GPU. O fast clear, o attachment load/store e a troca de passes do Metal continuam sob controle da GPU e do driver.

Isso também explica por que as áreas vazias permaneciam fluidas. O visibility culling ignora traços fora do viewport; em uma área vazia, fica próximo de zero, enquanto em uma área densa continua crescendo.

Uma resposta errada a 0.85 ms

O aplicativo já tinha uma textura de tinta da página inteira baked em dois pixels por ponto. Tentamos exibir essa textura diretamente durante a escrita, o deslocamento e o zoom, mantendo apenas o traço atual do Apple Pencil como vetor ao vivo; depois do fim da interação, um quadro adicional renderizaria novamente o resultado vetorial em alta resolução.

Essa abordagem teve um desempenho muito bom. Na mesma área densa com zoom de 300%, o tempo de GPU caiu 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 tela por ponto, mas o cache fornecia apenas dois. No instante em que o Apple Pencil tocava a tela, todos os traços existentes viravam uma imagem suave e de baixa resolução; ao levantar o Pencil, eles voltavam à nitidez total.

O tester disse uma coisa: “Quando estou escrevendo, a tela inteira fica borrada. Ela fica nítida de novo assim que eu solto.”

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

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

A correção final preservou o scratch por traço e a composição por traço, reduzindo apenas o escopo 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 essa caixa delimitadora nas coordenadas do drawable do viewport atual e adiciona dois pixels de margem para antialiasing:

O mesmo retângulo scissor é usado em três operações: 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 também é usada no baking e no partial replay da textura de tinta 4096², para que a exibição em alto zoom e a camada de tinta consolidada não produzam dois comportamentos diferentes de giz.

Há dois detalhes fáceis de não perceber.

Primeiro, o loadAction = .clear de um render pass ocorre durante a etapa de carregamento do attachment e não é limitado pelo scissor de rasterização. Continuar usando essa opção ainda limparia toda a textura scratch. O pass corrigido usa .dontCare e depois desenha um clear_fragment dentro do scissor. Esse retângulo é escrito por completo em seguida, e o composite lê somente o mesmo retângulo, portanto o conteúdo antigo do attachment não precisa ser carregado.

Segundo, depois que termina a composição de cada traço de giz, o scissor externo precisa ser restaurado. Se essa linha de restauração de estado for omitida, as canetas, imagens ou seleções seguintes continuarão recortadas pelos limites do traço de giz anterior, parecendo traços ou imagens ausentes.

A granulação do giz ainda é amostrada a partir de coordenadas absolutas do canvas, não de UVs locais. Mover o scissor só altera quais pixels a GPU processa; não altera qual posição da textura de granulação cada pixel lê. Portanto, retângulos adjacentes não produzem emendas na textura, e arrastar o canvas não faz a granulação deslizar.

Considerando apenas a carga de pixels, o novo escopo de trabalho fica próximo de

onde é a área da caixa delimitadora alinhada aos eixos do -ésimo traço de giz na tela atual. O número de encoders de renderização não diminuiu, mas cada limpeza e composição agora fica limitada à caixa delimitadora do traço na tela.

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

A maior parte dos traços de giz na página compartilha a mesma cor e densidade, e é tentador desenhar dezenas deles no scratch de uma vez e compor somente uma vez. Isso reduziria ainda mais os render passes, mas mudaria a semântica de cor e granulação nas regiões de sobreposição.

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

enquanto mesclar primeiro os corpos e aplicar um único gate produz

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

O agrupamento exato exige provar que os pixels dos traços são mutuamente disjuntos ou alocar uma região de atlas independente para cada traço e compor na ordem original. A aceitação final no dispositivo manteve a abordagem scissor, então esta rodada não introduziu um atlas nem a complexidade de seu gerenciamento.

De um bilhão 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
Escopo teórico dos retângulos por quadro (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Também verificamos os limites de clipping usando 3,452 traços e 202,710 pontos de amostra de um documento do dispositivo. Nos zooms 0.5×, 1×, 2×, 3×, 5× e 8×, foram gerados 186,408 casos de viewport; todo point sprite que pudesse produzir cobertura diferente de zero ficava dentro do scissor calculado. Esse teste cobriu as bordas do canvas, as bordas 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. Em zoom baixo, a textura de tinta da página inteira continua sendo exibida; em zoom alto, os traços visíveis continuam sendo redesenhados como vetores. No mesmo lado do limite, tocar com a caneta e deslocar a tela não substituem os traços existentes por outro nível de nitidez. Durante o redesenho vetorial em alto zoom, a limpeza e a composição scratch de cada traço de giz cobrem somente sua própria caixa delimitadora na tela.

O tempo de GPU não capturou a nitidez

Problemas de desempenho da GPU não necessariamente escalam com a quantidade mais visível na estrutura de dados. Neste caso, os 3,571 pontos de entrada eram um suspeito fácil; o tempo por quadro era determinado pelo trabalho em tela cheia disparado por cada um dos 70 traços de giz, junto com a troca de render passes.

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 de granulação no canvas não podiam ser removidos livremente. Mesma cor e mesma densidade significam apenas que os parâmetros coincidem — não provam que resultados sobrepostos possam ser mesclados.

O feedback no dispositivo real rejeitou a versão com o menor tempo de GPU. A observação “a tela inteira fica borrada” forneceu a restrição de produto que a medição sozinha não expressava: quando o Apple Pencil toca a tela, os usuários também estão observando 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 em alto zoom apenas limita o trabalho de cada traço de giz à sua própria caixa delimitadora na tela. Depois de ser carregada novamente no LucasPad, a resposta foi: “Ficou ótimo.”