Lulucat

Reconstruir o pipeline de renderização do Lulucat Notes com o Kimi K3

Gaoge ZhangGaoge Zhang

O nosso pipeline de mosaicos em Core Graphics tornou-se num pipeline de point sprites em Metal em seis pequenos passos, cada um verificado num iPad real. O código foi escrito em programação aos pares com o Kimi K3 na Fireworks.

Atualizado

Na semana passada, arrastar uma seleção de laço pelo Lulucat Notes produzia uma onda visível: no mesmo frame, alguns mosaicos do ecrã mostravam a seleção na nova posição, enquanto outros ainda mostravam a antiga. Substituímos todo o pipeline de renderização — bitmap em Core Graphics mais CATiledLayer — por Metal, em seis pequenos passos, cada um verificado num iPad real antes de começar o seguinte.

Mais uma nota sobre a autoria, porque importa para a segunda metade deste artigo: o código foi escrito em programação aos pares com o Kimi K3, o modelo aberto da Moonshot, a correr na Fireworks. A pessoa conduziu o trabalho, tomou decisões e testou; o modelo escreveu quase todas as linhas.

O pipeline

O pipeline antigo tinha dois tipos de desenho que foram divergindo lentamente: os traços eram incorporados numa bitmap para uma visualização barata e redesenhados como vetores sempre que os mosaicos precisavam de mais detalhe. O novo pipeline tem exatamente uma ideia: tudo o que parece tinta é um point sprite. Um traço de caneta, uma passagem de marcador e um toque de borracha são o mesmo vértice de 32 bytes — posição, diâmetro, cor — desenhado pelo mesmo par de shaders como círculos rasterizados na GPU, espaçados um ponto ao longo do comprimento de arco do traço.

Três painéis do mesmo traço curvo: pontos de toque de entrada, selos circulares espaçados ao longo da curva por comprimento de arco e o traço sólido composto.

Com um espaçamento de um ponto, uma cadeia de círculos desvia-se de uma cápsula matematicamente perfeita em cerca de 0.075 pontos — um quinto de um pixel na densidade do nosso canvas. Em contrapartida, três ferramentas colapsam num único caminho de código, e a GPU faz aquilo em que é melhor.

À volta dessa ideia, a arquitetura é simples. A tinta consolidada vive numa única textura de 4096². O UIScrollView sobrevive, despromovido a um puro motor de gestos: o seu contentOffset e zoomScale alimentam um uniform de viewport em cada frame, pelo que deslocar e ampliar não escrevem nada. Cada frame é composto por cinco desenhos:

flowchart TB
    subgraph frame["Every frame: five draws"]
        direction TB
        paper["1 · paper blit"] --> ink["2 · committed ink"]
        ink --> live["3 · live stroke"]
        live --> sel["4 · selection"]
        sel --> dash["5 · lasso dashes"]
    end
    commit["stroke commit<br/>append stamps"] --> tex[("ink texture<br/>4096² render target")]
    replay["regional replay<br/>erase · delete · move · undo"] --> tex
    tex -. "sampled or re-drawn" .-> ink

As edições escrevem diretamente na textura. Consolidar um traço acrescenta os seus selos. Apagar, eliminar, mover e anular reproduzem a região afetada com um retângulo de scissor: limpa-se a região, redesenham-se os traços que a intersectam, está feito. O apagamento parcial mantém a semântica de propriedade do artigo anterior — um apagamento pertence ao traço de onde remove tinta — desenhando cada traço apagado numa textura scratch, subtraindo os seus próprios caminhos de apagamento com blending destination-out () e compondo o resultado de volta. O isolamento na textura scratch impede uma borracha de morder através do papel ou dos traços vizinhos.

A seleção que deu origem a tudo isto é agora também desenhada como point sprites. Arrastá-la atualiza um único offset de uniform. Zero escritas na textura, zero invalidações de mosaicos — a onda desapareceu estruturalmente, não foi mitigada.

O guardião: um diff de pixels

Não apagámos o renderizador de Core Graphics. Despromovemo-lo a uma implementação de referência offline, e todas as alterações ao Metal têm de passar por uma comparação de pixels com ele, usando dados reais de traços capturados no dispositivo. O critério de aceitação não é “pixels idênticos” — dois rasterizadores corretos podem legitimamente divergir em alguns níveis de cinzento ao longo de arestas com anti-aliasing. O critério é estrutural: sem tinta em falta, sem offset, sem desvio de cor e sem grande diferença em lado nenhum longe da tinta.

Três recortes das mesmas notas manuscritas: desenhadas por Core Graphics, desenhadas por point sprites em Metal e a sua diferença de pixels ampliada seis vezes, mostrando apenas contornos ténues ao longo das arestas dos traços.

Este harness apanhou quatro dos cinco bugs com que nos deparámos ao construir o renderizador offline, todos diagnosticados recortando o resultado ao nível do pixel: um desfasamento de stride de struct Swift/Metal (28 bytes contra 32, porque o Metal alinha float4 a 16 — o ecrã encheu-se de blocos de cor), o [[point_size]] ser ilegível como varying no fragment shader (todos os selos saíam quadrados), dois render encoders a coexistirem num único command buffer (tudo preto) e um toque inicial em falta que deixava invisível o primeiro milímetro dos traços rápidos.

Seis passos, não uma reescrita

O plano de migração consistia em seis passos que podiam ser lançados independentemente: renderizador offline a passar o pixel diff; shell de visualização sem alterações visuais; traço ao vivo na GPU; seleção na GPU; mutações a escrever diretamente na textura; e redesenho vetorial com grande ampliação (incorporado no passo dois, porque “zero alteração visual” o exigia). Cada passo terminava com uma pessoa — não um simulador, não um diff de captura de ecrã — a escrever, apagar, ampliar e arrastar no iPad Pro que estava sobre a secretária.

O dispositivo detetou três bugs que passaram por todas as verificações automatizadas. Acima dos 100% de zoom, os traços eram desenhados duas vezes — textura suave por baixo, sprites nítidos por cima — o que se lia como um ligeiro desfocamento que a pessoa notava em segundos. Consolidar um traço cintilava durante um frame porque o overlay antigo fazia cross-fade fora de sincronia com a atualização da textura. E acima dos 170% de zoom, todas as notas desapareciam: o retângulo de culling de visibilidade usava o contentOffset no seu espaço de coordenadas escalado, pelo que se afastava dos traços à medida que se ampliava. Cada um dos três foi corrigido com uma alteração de uma linha numa única função, e nenhum deles existia em qualquer teste que pudéssemos ter escrito antecipadamente, porque não sabíamos que devíamos procurá-los. Numa aplicação de consumo centrada na UI, é por isso que a pessoa permanece no ciclo: não se conseguem enumerar as formas como a “sensação” se estraga.

Como é trabalhar com o Kimi K3

Rápido, antes de mais. O ciclo de “discutir, escrever, compilar, instalar, olhar” demorava poucos minutos, e um modelo que responde depressa muda quantos ciclos se podem fazer por dia.

Segundo, não faz over-engineering. Esta codebase funciona com regras da casa explícitas — sem scaffolding de retrocompatibilidade antes do lançamento, complexidade apenas quando um dispositivo prova que é necessária — e o K3 segue-as sem que seja preciso lembrá-lo. Não adicionou índices espaciais “para mais tarde”, não envolveu cada chamada em verificações defensivas, não abstraiu especulativamente. Promptá-lo parece trabalhar com um colega competente que leu as regras da casa e realmente acredita nelas.

Terceiro, deem-lhe ferramentas e ele usa-as avidamente. Ligámos utilitários de imagem — ver, recortar para uma região de píxeis, redimensionar — e o modelo começou a recortar proativamente o resultado do seu próprio renderizador para diagnosticar os cinco bugs do harness acima. O facto de ter a ferramenta lembrava-o de olhar.

A outra metade: o K3 escreveu a maioria dos bugs nesta história, incluindo o do espaço de coordenadas que fazia desaparecer as notas. Os seus limites são reais. O que tornou o trabalho seguro nunca foi o modelo ter razão; foi o harness a apanhar o desvio de renderização e o humano a apanhar a sensação. E, no entanto, no dia a dia, não conseguia distinguir, de forma fiável, o K3 dos modelos fechados de topo que também usamos — sistemas da classe do Opus. Nalguns aspetos era claramente melhor: mais rápido e muito menos inclinado a inchar a codebase com design defensivo.

Como queremos construir a partir de agora

Estamos a abandonar o desenvolvimento agêntico baseado em grandes especificações — o estilo em que se entrega a um modelo uma grande especificação e se aceita o que quer que seja entregue. O modo de falha não é mau código; é código que ninguém percebe.

O que funcionou aqui, e o que vamos manter: passos pequenos, cada um discutido antes de começar, cada um compreendido pelo humano antes de ser construído, cada um verificado no dispositivo onde vai viver. O trabalho do modelo é ser rápido, preciso e honesto sobre a incerteza. O trabalho da pessoa é o juízo, o gosto e a verificação e2e — especialmente para software de consumo centrado na UI, onde a especificação não consegue descrever como é a sensação de “certo”. O Kimi K3 na Fireworks revela-se bem talhado exatamente para este ciclo: rápido o suficiente para manter o ciclo apertado, inteligente o suficiente para manter os passos pequenos e limpos.

A onda desapareceu, o pipeline é uma ideia em vez de duas, e o processo que nos trouxe até aqui fica.