Reconstruir el pipeline de renderizado de Lulucat Notes con Kimi K3
Nuestro pipeline de teselas de Core Graphics se convirtió en un pipeline de sprites de punto de Metal en seis pasos pequeños, cada uno verificado en un iPad real. El código se programó en pareja con Kimi K3 en Fireworks.
La semana pasada, al arrastrar una selección de lazo por Lulucat Notes, se producía una onda visible: algunas teselas de la pantalla mostraban la selección en su nueva posición, mientras otras seguían mostrando la anterior en el mismo fotograma. Reemplazamos todo el pipeline de renderizado — mapa de bits de Core Graphics más CATiledLayer — por Metal en seis pasos pequeños, cada uno verificado en un iPad real antes de comenzar el siguiente.
El código se escribió en pareja con Kimi K3, el modelo abierto de Moonshot que se ejecutaba en Fireworks. La persona dirigía, tomaba las decisiones y probaba; el modelo escribió casi todas las líneas.
El pipeline
El pipeline antiguo tenía dos tipos de dibujo que se fueron separando poco a poco: los trazos se incorporaban a un mapa de bits para mostrarlos a bajo coste, y se volvían a dibujar como vectores cuando las teselas necesitaban más detalle. El nuevo pipeline tiene una sola idea: todo lo que parece tinta es un sprite de punto. Un trazo de pluma, un barrido de resaltador y un toque de borrador son el mismo vértice de 32 bytes — posición, diámetro, color — que el mismo par de shaders dibuja como círculos rasterizados por la GPU, separados por un punto a lo largo de la longitud de arco del trazo.
![]()
Con una separación de un punto, una cadena de círculos se desvía de una cápsula matemáticamente perfecta en aproximadamente 0.075 puntos — una quinta parte de un píxel a la densidad de nuestro lienzo. A cambio, tres herramientas se reducen a una sola ruta de código y la GPU hace lo que mejor sabe hacer.
A partir de esa idea, la arquitectura es sencilla. La tinta consolidada vive en una sola textura de 4096². El UIScrollView sobrevive, relegado a ser un motor de gestos puro: su contentOffset y zoomScale alimentan un uniform de viewport en cada fotograma, así que el desplazamiento y el zoom no escriben nada. Cada fotograma consta de cinco dibujos:
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
Las ediciones escriben directamente en la textura. Consolidar un trazo añade sus sellos. Borrar, eliminar, mover y deshacer reproducen la región afectada con un rectángulo de scissor: se borra la región, se vuelven a dibujar los trazos que la intersectan y listo. El borrado parcial conserva la semántica de pertenencia de la publicación anterior — un borrado pertenece al trazo del que elimina tinta — al dibujar cada trazo borrado en una textura temporal, restar sus propias rutas de borrado con mezcla destination-out (
La selección que inició todo esto ahora también se dibuja como sprites de punto. Arrastrarla actualiza un único desplazamiento de uniform. Cero escrituras en la textura, cero invalidaciones de teselas — la onda ha desaparecido estructuralmente, no se ha mitigado.
El guardián: una diferencia de píxeles
No eliminamos el renderizador de Core Graphics. Lo relegamos a una implementación de referencia sin conexión, y cada cambio de Metal debe superar una comparación de píxeles con él usando datos reales de trazos capturados en el dispositivo. El criterio de aceptación no es «píxeles idénticos»: dos rasterizadores correctos pueden discrepar legítimamente en unos pocos niveles de gris a lo largo de los bordes suavizados. El criterio es estructural: no debe faltar tinta, no debe haber desplazamientos ni deriva de color, ni diferencias grandes en ningún punto alejado de la tinta.
![]()
Este arnés detectó cuatro de los cinco fallos que encontramos al construir el renderizador sin conexión, todos diagnosticados al recortar la salida hasta el nivel de píxel: una discrepancia en el stride de una estructura Swift/Metal (28 bytes frente a 32, porque Metal alinea float4 a 16 — la pantalla se llenó de bloques de color), la imposibilidad de leer [[point_size]] como varying en el fragment shader (cada sello salía cuadrado), la coexistencia de dos render encoders en un mismo command buffer (todo negro) y la falta de un toque inicial que dejaba invisible el primer milímetro de los trazos rápidos.
Seis pasos, no una reescritura
El plan de migración constaba de seis pasos que se podían entregar de forma independiente: un renderizador sin conexión que superara la diferencia de píxeles; una capa de visualización sin ningún cambio visual; el trazo en vivo en la GPU; la selección en la GPU; las mutaciones escribiendo directamente en la textura; y el redibujado vectorial con zoom alto (integrado en el paso dos, porque «cero cambios visuales» lo exigía). Cada paso terminaba con una persona — no un simulador ni una diferencia de capturas de pantalla — que escribía, borraba, hacía zoom y arrastraba en el iPad Pro que estaba sobre el escritorio.
El dispositivo detectó tres fallos que todas las comprobaciones automatizadas pasaron por alto. Por encima del 100 % de zoom, los trazos se dibujaban dos veces — una textura suave debajo y sprites nítidos encima —, lo que se percibía como un desenfoque tenue que la persona detectó en segundos. Al consolidar un trazo, la imagen parpadeaba durante un fotograma porque la superposición antigua hacía un fundido cruzado desincronizado con la actualización de la textura. Y por encima del 170 % de zoom desaparecían todas las notas: el rectángulo de descarte de visibilidad usaba contentOffset en su espacio de coordenadas escalado, así que se alejaba de los trazos al hacer zoom. Los tres se solucionaron con un cambio de una línea en una función, y ninguno existía en una prueba que hubiéramos podido escribir de antemano, porque no sabíamos que debíamos buscarlo. En una aplicación de consumo centrada en la interfaz, por eso la persona sigue formando parte del bucle.
Cómo es trabajar con Kimi K3
Rápido, ante todo. El ciclo de «discutir, escribir, compilar, instalar, mirar» se ejecutaba en minutos, y un modelo que responde rápido cambia cuántos ciclos puedes permitirte en un día.
En segundo lugar, no sobrediseña. Este código funciona con reglas internas explícitas — no se añade infraestructura de compatibilidad hacia atrás antes del lanzamiento, y la complejidad solo aparece cuando un dispositivo demuestra que hace falta —, y K3 las sigue sin que haya que recordárselas. No añadió índices espaciales «para más adelante», no envolvió cada llamada en comprobaciones defensivas ni creó abstracciones especulativas. Pedirle cosas se siente como trabajar con un colega competente que ha leído las reglas internas y realmente cree en ellas.
En tercer lugar, dale herramientas y las usa con entusiasmo. Conectamos utilidades de imagen — ver, recortar a una región de píxeles y cambiar el tamaño — y el modelo empezó a recortar de forma proactiva la salida de su propio renderizador para diagnosticar los cinco fallos del arnés mencionados arriba. Tener la herramienta le recordó que debía mirar.
La otra mitad: K3 escribió la mayoría de los fallos de esta historia, incluido el del espacio de coordenadas que hacía desaparecer las notas. Sus límites son reales. El trabajo no fue seguro porque el modelo acertara; lo hicieron seguro el arnés, que detectaba la deriva del renderizado, y la persona, que detectaba la sensación. Y, aun así, en el día a día no podía distinguirlo con fiabilidad de los modelos cerrados de vanguardia que también usamos — sistemas de clase Opus. En algunos aspectos era claramente mejor: más rápido y mucho menos propenso a rellenar el código con diseño defensivo.
Cómo queremos construir a partir de ahora
Hemos terminado con el desarrollo agéntico basado en especificaciones grandes — el estilo en el que entregas a un modelo una especificación extensa y aceptas lo que produzca. El modo de fallo no es el código malo; es el código que nadie entiende.
Lo que funcionó aquí y conservaremos: pasos pequeños, cada uno discutido antes de empezar, entendido por la persona antes de construirlo y verificado en el dispositivo donde vivirá. El trabajo del modelo es ser rápido, preciso y honesto sobre la incertidumbre. El de la persona es aportar criterio, gusto y verificación e2e — especialmente para software de consumo centrado en la interfaz, donde la especificación no puede describir cómo se siente lo que está «bien». Kimi K3 en Fireworks resulta estar bien adaptado a este bucle: lo bastante rápido para mantenerlo estrecho, lo bastante inteligente para mantener los pasos pequeños y limpios.
La onda ha desaparecido, el pipeline es una sola idea en lugar de dos y el proceso que nos llevó hasta aquí se queda.