Lulucat

Rebâtir le pipeline de rendu de Lulucat Notes avec Kimi K3

Gaoge ZhangGaoge Zhang

Notre pipeline de tuiles Core Graphics est devenu un pipeline Metal de point sprites en six petites étapes, chacune validée sur un vrai iPad. Le code a été écrit en duo avec Kimi K3 sur Fireworks.

Mis à jour

La semaine dernière, faire glisser une sélection au lasso dans Lulucat Notes créait une vague visible : certaines tuiles de l’écran montraient la sélection à sa nouvelle position, alors que d’autres affichaient encore l’ancienne dans la même frame. Nous avons remplacé tout le pipeline de rendu — bitmap Core Graphics plus CATiledLayer — par Metal en six petites étapes, chacune validée sur un vrai iPad avant de passer à la suivante.

Le code a été écrit en duo avec Kimi K3, le modèle ouvert de Moonshot qui fonctionnait sur Fireworks. L’humain pilotait, décidait et testait ; le modèle a écrit presque chaque ligne.

Le pipeline

L’ancien pipeline comportait deux façons de dessiner qui se sont lentement éloignées : les traits étaient intégrés à un bitmap pour un affichage peu coûteux, puis redessinés en vecteurs dès que les tuiles avaient besoin de plus de détails. Le nouveau pipeline repose sur une seule idée : tout ce qui ressemble à de l’encre est un point sprite. Un trait de stylet, un coup de surligneur et une touche de gomme sont le même vertex de 32 octets — position, diamètre, couleur — dessiné par la même paire de shaders sous forme de cercles rastérisés par le GPU, espacés d’un point le long de la longueur d’arc du trait.

Trois panneaux du même trait courbe : points tactiles d’entrée, tampons circulaires espacés sur la courbe selon sa longueur d’arc et trait plein composé.

Avec un espacement d’un point, une chaîne de cercles s’écarte d’une capsule mathématiquement parfaite d’environ 0.075 point — un cinquième de pixel à la densité de notre canevas. En échange, trois outils se réduisent à un seul chemin de code, et le GPU fait ce qu’il sait faire de mieux.

Autour de cette idée, l’architecture est simple. L’encre confirmée vit dans une seule texture de 4096². Le UIScrollView reste en place, relégué au rôle de simple moteur de gestes : son contentOffset et son zoomScale alimentent un uniform de viewport à chaque frame, alors que le panoramique et le zoom n’écrivent rien. Chaque frame comprend cinq opérations de dessin :

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

Les modifications écrivent directement dans la texture. La validation d’un trait ajoute ses tampons. Effacer, supprimer, déplacer et annuler rejouent la région touchée à l’intérieur d’un rectangle scissor : on efface la région, on redessine les traits qui la croisent, terminé. L’effacement partiel conserve la sémantique de propriété de l’article précédent — un effacement appartient au trait auquel il retire de l’encre — en dessinant chaque trait effacé dans une texture temporaire, en soustrayant ses propres chemins d’effacement avec un mélange destination-out (), puis en recomposant le résultat. L’isolement de la texture temporaire empêche une gomme de traverser le papier ou les traits voisins.

La sélection qui a lancé tout ça est maintenant dessinée elle aussi comme des point sprites. La faire glisser met à jour un seul décalage de uniform. Zéro écriture dans la texture, zéro invalidation de tuile — la vague a disparu structurellement, elle n’est pas simplement atténuée.

Le garde-fou : une différence de pixels

Nous n’avons pas supprimé le moteur de rendu Core Graphics. Nous l’avons relégué au rôle d’implémentation de référence hors ligne, et chaque changement dans Metal doit réussir une comparaison de pixels avec lui à partir de vraies données de traits capturées sur l’appareil. Le critère d’acceptation n’est pas « des pixels identiques » — deux rasteriseurs corrects peuvent légitimement différer de quelques niveaux de gris le long des contours anticrénelés. Le critère est structurel : aucune encre manquante, aucun décalage, aucune dérive de couleur et aucune différence importante loin de l’encre.

Trois recadrages des mêmes notes manuscrites : rendues par Core Graphics, rendues par des point sprites Metal et leur différence de pixels amplifiée six fois, montrant seulement de faibles contours le long des bords des traits.

Ce banc d’essai a détecté quatre des cinq bugs rencontrés pendant la construction du moteur de rendu hors ligne, tous diagnostiqués en recadrant la sortie au niveau du pixel : un écart de stride dans une structure Swift/Metal (28 octets contre 32, parce que Metal aligne float4 sur 16 — l’écran s’est rempli de blocs de couleur), l’impossibilité de lire [[point_size]] comme varying dans le fragment shader (chaque tampon était carré), deux render encoders coexistants sur un même command buffer (tout était noir) et une touche de départ manquante qui rendait invisible le premier millimètre des traits rapides.

Six étapes, pas une réécriture

Le plan de migration comptait six étapes pouvant être livrées indépendamment : un moteur de rendu hors ligne qui passait la différence de pixels ; une couche d’affichage sans aucun changement visuel ; le trait en direct sur le GPU ; la sélection sur le GPU ; les mutations écrivant directement dans la texture ; et le redessin vectoriel à fort zoom (intégré à l’étape deux, parce que « zéro changement visuel » l’exigeait). Chaque étape se terminait par une personne — pas un simulateur, pas une comparaison de captures d’écran — qui écrivait, effaçait, zoomait et faisait glisser sur l’iPad Pro posé sur le bureau.

L’appareil a trouvé trois bugs que toutes les vérifications automatisées avaient manqués. Au-delà de 100 % de zoom, les traits étaient dessinés deux fois — texture douce dessous, sprites nets dessus —, ce qui donnait un léger flou que l’humain a remarqué en quelques secondes. La validation d’un trait scintillait pendant une frame, parce que l’ancienne superposition faisait un fondu croisé désynchronisé avec la mise à jour de la texture. Et au-delà de 170 % de zoom, toutes les notes disparaissaient : le rectangle d’élagage de la visibilité utilisait contentOffset dans son espace de coordonnées mis à l’échelle, et s’éloignait donc des traits à mesure qu’on zoomait. Les trois bugs ont été corrigés par une modification d’une ligne dans une fonction, et aucun n’existait dans un test qu’on aurait pu écrire à l’avance, parce qu’on ne savait pas qu’il fallait les chercher. Pour une application grand public conçue d’abord autour de l’interface, c’est pourquoi l’humain reste dans la boucle.

À quoi ressemble le travail avec Kimi K3

Rapide, d’abord. La boucle « discuter, écrire, construire, installer, regarder » roulait en quelques minutes, et un modèle qui répond vite change le nombre de boucles qu’on peut se permettre dans une journée.

Deuxièmement, il n’en fait pas trop côté architecture. Cette base de code suit des règles maison explicites — pas de structure de rétrocompatibilité avant le lancement, de la complexité seulement lorsqu’un appareil prouve qu’elle est nécessaire — et K3 les suit sans qu’on ait à les lui rappeler. Il n’a pas ajouté d’index spatiaux « pour plus tard », n’a pas enveloppé chaque appel dans des vérifications défensives et n’a pas créé d’abstractions spéculatives. Lui donner des instructions donne l’impression de travailler avec un collègue compétent qui a lu les règles maison et y croit vraiment.

Troisièmement, quand on lui donne des outils, il les utilise avec empressement. Nous avons branché des utilitaires d’image — voir, recadrer une région de pixels, redimensionner — et le modèle a commencé à recadrer proactivement la sortie de son propre moteur de rendu pour diagnostiquer les cinq bugs du banc d’essai mentionnés plus haut. Le fait d’avoir l’outil lui rappelait de regarder.

L’autre moitié : K3 a écrit la plupart des bugs de cette histoire, y compris celui d’espace de coordonnées qui faisait disparaître les notes. Ses limites sont réelles. Le travail n’a jamais été sûr parce que le modèle avait raison ; il l’était parce que le banc d’essai détectait les dérives du rendu et que l’humain détectait le ressenti. Et pourtant, au quotidien, je n’arrivais pas à le distinguer de manière fiable des modèles fermés de pointe que nous utilisons aussi — des systèmes de classe Opus. À certains égards, il était clairement meilleur : plus rapide et beaucoup moins porté à remplir la base de code de conception défensive.

Comment nous voulons bâtir la suite

Nous en avons fini avec le développement agentique fondé sur de grandes spécifications — le style où l’on remet une grande spécification à un modèle et où l’on accepte tout ce qui en sort. Le mode d’échec n’est pas le mauvais code ; c’est le code que personne ne comprend.

Ce qui a marché ici et qu’on va garder : de petites étapes, chacune discutée avant de commencer, comprise par l’humain avant d’être construite et vérifiée sur l’appareil où elle vivra. Le travail du modèle est d’être rapide, précis et honnête face à l’incertitude. Le travail de l’humain est d’exercer son jugement, son goût et sa validation e2e — surtout pour un logiciel grand public conçu d’abord autour de l’interface, où la spécification ne peut pas décrire ce qu’on ressent quand c’est « bon ». Kimi K3 sur Fireworks s’avère parfaitement adapté à cette boucle : assez rapide pour la garder serrée, assez intelligent pour que les étapes restent petites et propres.

La vague a disparu, le pipeline est une seule idée plutôt que deux, et le processus qui nous a menés jusqu’ici reste.