Lulucat

Reconstruire le pipeline de rendu de Lulucat Notes avec Kimi K3

Gaoge ZhangGaoge Zhang

En six petites étapes, notre pipeline de tuiles Core Graphics est devenu un pipeline Metal de sprites ponctuels, chaque étape étant vérifiée sur un véritable iPad. Le code a été écrit en binôme avec Kimi K3 sur Fireworks.

Mis à jour

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

Le code a été écrit en binôme 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 modes de dessin qui se sont lentement éloignés : 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 sprite ponctuel. Un trait de stylet, un balayage 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 de contact en entrée, tampons circulaires espacés sur la courbe selon la 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 validée vit dans une texture unique de 4096². UIScrollView reste en place, rétrogradé au rang de moteur de gestes pur : son contentOffset et son zoomScale alimentent un uniform de viewport à chaque image ; le panoramique et le zoom n’écrivent donc rien. Chaque image comporte cinq dessins :

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 concernée dans les limites d’un rectangle de scissor : on efface la région, on redessine les traits qui l’intersectent, 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’isolation de la texture temporaire empêche une gomme de traverser le papier ou les traits voisins.

La sélection à l’origine de tout cela est maintenant dessinée elle aussi sous forme de sprites ponctuels. 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 gardien : 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 modification de Metal doit réussir une comparaison de pixels avec lui sur 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 diverger de quelques niveaux de gris le long des bords anticrénelés. Le critère est structurel : aucune encre manquante, aucun décalage, aucune dérive de couleur et aucune différence importante en dehors de l’encre.

Trois recadrages des mêmes notes manuscrites : rendues par Core Graphics, rendues par des sprites ponctuels 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, car 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 initiale manquante qui rendait invisible le premier millimètre des traits rapides.

Six étapes, pas une réécriture

Le plan de migration comportait six étapes pouvant être livrées indépendamment : un moteur de rendu hors ligne passant la comparaison de pixels ; une couche d’affichage sans 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 tous les contrôles automatisés 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 repéré en quelques secondes. La validation d’un trait scintillait pendant une image, car l’ancienne surcouche effectuait un fondu croisé désynchronisé de 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 dérivait donc par rapport aux traits à mesure que l’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 que nous aurions pu écrire à l’avance, car nous ne savions pas qu’il fallait les chercher. Pour une app grand public pensée d’abord pour l’interface, c’est pourquoi l’humain reste dans la boucle.

Ce que c’est de travailler avec Kimi K3

Rapide, avant tout. La boucle « discuter, écrire, construire, installer, regarder » s’exécutait en quelques minutes, et un modèle qui répond vite change le nombre de boucles que l’on peut se permettre dans une journée.

Deuxièmement, il ne surconçoit pas. Cette base de code suit des règles internes explicites — pas d’ossature de rétrocompatibilité avant le lancement, de la complexité uniquement 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 abstrait de manière spéculative. Lui donner des instructions donne l’impression de travailler avec un collègue compétent qui a lu les règles internes et y croit vraiment.

Troisièmement, donnez-lui des outils et 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. La présence de 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 enclin à alourdir la base de code avec une conception défensive.

Comment nous voulons construire désormais

Nous en avons fini avec le développement agentique fondé sur de grandes spécifications — le style où l’on remet une grosse spécification à un modèle et où l’on accepte 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 fonctionné ici et que nous conserverons : de petites étapes, chacune discutée avant de commencer, comprise par l’humain avant d’être construite, puis vérifiée sur l’appareil où elle vivra. Le rôle du modèle est d’être rapide, précis et honnête sur l’incertitude. Le rôle de l’humain est d’apporter son jugement, son goût et une vérification e2e — surtout pour un logiciel grand public pensé d’abord pour l’interface, où la spécification ne peut pas décrire ce que l’on ressent quand c’est « juste ». Kimi K3 sur Fireworks s’avère justement bien adapté à cette boucle : assez rapide pour la garder serrée, assez intelligent pour maintenir des étapes petites et propres.

La vague a disparu, le pipeline est une seule idée au lieu de deux, et le processus qui nous y a conduits reste.