Reconstruire le pipeline de rendu de Lulucat Notes avec Kimi K3
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.
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.
![]()
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 (
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.
![]()
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.