Lulucat

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

Gaoge ZhangGaoge Zhang

Nosso pipeline de tiles em Core Graphics virou um pipeline de point sprites em Metal em seis pequenos passos, cada um verificado em um iPad real. O código foi feito em pair programming com Kimi K3 no Fireworks.

Atualizado

Na semana passada, arrastar uma seleção de laço pelo Lulucat Notes produziu uma onda visível: no mesmo frame, alguns tiles da tela 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 em um iPad real antes de o próximo começar.

Mais uma coisa sobre autoria, porque importa para a segunda metade deste post: o código foi feito em pair programming com Kimi K3, o modelo aberto da Moonshot, rodando no 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 se afastando devagar: os traços eram incorporados a um bitmap para exibição barata e redesenhados como vetores sempre que os tiles precisavam de mais detalhe. O novo pipeline tem exatamente uma ideia: tudo que parece tinta é um point sprite. Um traço de caneta, uma passada de marca-texto e um toque de borracha são o mesmo vértice de 32 bytes — posição, diâmetro, cor — desenhados pelo mesmo par de shaders como círculos rasterizados na GPU, espaçados de 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, carimbos circulares espaçados ao longo da curva pelo comprimento de arco, e o traço sólido composto.

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

Em torno dessa ideia, a arquitetura é simples. A tinta consolidada vive em uma única textura 4096². O UIScrollView sobrevive, rebaixado a um motor puro de gestos: seus contentOffset e zoomScale alimentam um uniform de viewport a cada frame, então pan e zoom não escrevem nada. Cada frame tem cinco draws:

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. Confirmar um traço acrescenta seus carimbos. Apagar, deletar, mover e desfazer reproduzem a região afetada usando um scissor rectangle: limpe a região, redesenhe os traços que a intersectam, pronto. O apagamento parcial mantém a semântica de posse do post anterior — um apagamento pertence ao traço do qual remove tinta — desenhando cada traço apagado em uma textura scratch, subtraindo seus próprios caminhos de apagamento com blending destination-out () e compondo o resultado de volta. O isolamento da scratch impede que a borracha morda através do papel ou dos traços vizinhos.

A seleção que começou tudo isso agora também é desenhada como point sprites. Arrastá-la atualiza um único uniform de offset. Zero escritas em textura, zero invalidações de tiles — a onda acabou estruturalmente, não foi mitigada.

O guardião: um pixel diff

Nós não deletamos o renderizador em Core Graphics. Nós o rebaixamos a uma implementação de referência offline, e toda mudança em Metal tem que passar por uma comparação de pixels contra ele, com dados reais de traços capturados no dispositivo. O critério de aceitação não é “pixels idênticos” — dois rasterizadores corretos legitimamente discordam em alguns níveis de cinza ao longo de bordas com anti-aliasing. O gate é estrutural: sem tinta faltando, sem offset, sem desvio de cor e sem grande diferença em nenhum lugar longe da tinta.

Três recortes das mesmas notas manuscritas: renderizadas por Core Graphics, renderizadas por point sprites em Metal, e sua diferença de pixels amplificada seis vezes, mostrando apenas contornos fracos ao longo das bordas dos traços.

Esse harness pegou quatro dos cinco bugs que enfrentamos construindo o renderizador offline, todos diagnosticados recortando a saída até o nível de pixel: um descompasso de stride de struct entre Swift/Metal (28 bytes contra 32, porque Metal alinha float4 a 16 — a tela se encheu de blocos de cor), [[point_size]] ilegível como varying no fragment shader (todo carimbo saía quadrado), dois render encoders coexistindo em um command buffer (tudo preto), e um toque inicial faltando que deixava o primeiro milímetro dos traços rápidos invisível.

Seis passos, não uma reescrita

O plano de migração consistia em seis passos lançáveis de forma independente: renderizador offline passando o pixel diff; shell de exibição com zero mudança visual; traço ao vivo na GPU; seleção na GPU; mutações escrevendo a textura diretamente; redesenho vetorial em zoom alto (incorporado ao passo dois, porque “zero mudança visual” exigia isso). Cada passo terminava com uma pessoa — não um simulador, não um diff de screenshot — escrevendo, apagando, dando zoom e arrastando no iPad Pro sobre a mesa.

O dispositivo encontrou três bugs que todas as verificações automatizadas deixaram passar. Acima de 100% de zoom, os traços eram desenhados duas vezes — textura suave por baixo, sprites nítidos por cima — o que aparecia como um leve desfoque que a pessoa notava em segundos. Confirmar um traço piscava por um frame porque o overlay antigo fazia cross-fade fora de sincronia com a atualização da textura. E acima de 170% de zoom, todas as notas sumiam: o retângulo de visibility-culling usava contentOffset em seu espaço de coordenadas escalado, então se afastava dos traços conforme você dava zoom. Cada um dos três foi corrigido com uma alteração de uma linha em uma única função, e nenhum deles existia em nenhum teste que pudéssemos ter escrito antes, porque não sabíamos que devíamos procurá-los. Para um app consumer focado em UI, é por isso que a pessoa continua no loop: não dá para enumerar as formas como o “feel” quebra.

Como é trabalhar com o Kimi K3

Rápido, antes de tudo. O loop de “discutir, escrever, buildar, instalar, olhar” durava poucos minutos, e um modelo que responde rápido muda quantos loops você consegue bancar em um dia.

Segundo, ele não faz overengineering. Esta codebase roda em house rules explícitas — sem scaffolding de retrocompatibilidade antes do lançamento, complexidade só quando um dispositivo prova que é necessária — e o K3 as segue sem ser lembrado. Ele não adicionou índices espaciais “para depois”, não envelopou cada chamada em checagens defensivas, não abstraiu especulativamente. Promptá-lo parece trabalhar com um colega competente que leu as house rules e realmente acredita nelas.

Terceiro, dê ferramentas a ele e ele as usa com vontade. Nós conectamos utilidades de imagem — view, crop para uma região de pixel, resize — e o modelo começou proativamente a recortar a própria saída do renderizador para diagnosticar os cinco bugs de harness acima. A ferramenta o lembrou de olhar.

A outra metade: o K3 escreveu a maioria dos bugs nesta história, incluindo o de espaço de coordenadas que fazia as notas sumirem. Seus limites são reais. O que tornou o trabalho seguro nunca foi o modelo estar certo; foi o harness pegando desvios de renderização e a pessoa percebendo o feel. E, ainda assim, no dia a dia, eu não conseguia distingui-lo de forma confiável dos modelos fechados de ponta que também usamos — sistemas da classe Opus. Em alguns aspectos ele era claramente melhor: mais rápido, e muito menos inclinado a encher a codebase com design defensivo.

Como queremos construir daqui para frente

Estamos deixando para trás o desenvolvimento agêntico baseado em especificações grandes — o estilo em que você entrega a um modelo uma grande especificação e aceita o que quer que seja entregue. O modo de falha não é código ruim; é código que ninguém entende.

O que funcionou aqui, e o que vamos manter: passos pequenos, cada um discutido antes de começar, cada um entendido pelo humano antes de ser construído, cada um verificado no dispositivo em que vai viver. O trabalho do modelo é ser rápido, preciso e honesto sobre a incerteza. O trabalho da pessoa é julgamento, gosto e verificação e2e — especialmente para software consumer focado em UI, onde a especificação não consegue descrever como o “certo” parece. O Kimi K3 no Fireworks se mostrou bem adequado exatamente a esse loop: rápido o bastante para manter o loop apertado, esperto o bastante para manter os passos pequenos e limpos.

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