Volver a construir el pipeline de renderizado de Lulucat Notes con Kimi K3
Nuestro pipeline de mosaicos de Core Graphics se convirtió en un pipeline de point sprites 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: algunos mosaicos de la pantalla mostraban la selección en su nueva posición, mientras otros todavía mostraban la anterior dentro del mismo cuadro. Reemplazamos todo el pipeline de renderizado — bitmap 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 anterior tenía dos formas de dibujar que se fueron separando poco a poco: los trazos se integraban en un bitmap para mostrarlos a bajo costo, y se volvían a dibujar como vectores cuando los mosaicos necesitaban más detalle. El nuevo pipeline tiene una sola idea: todo lo que parece tinta es un point sprite. Un trazo de pluma, una pasada 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.
Alrededor de esa idea, la arquitectura es sencilla. La tinta confirmada 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 cuadro, así que el desplazamiento y el zoom no escriben nada. Cada cuadro contiene cinco operaciones de dibujo:
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 la textura directamente. Confirmar un trazo agrega sus sellos. Borrar, eliminar, mover y deshacer reproducen la región afectada mediante un rectángulo de scissor: se limpia 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 dio inicio a todo esto ahora también se dibuja como point sprites. Arrastrarla actualiza un solo desplazamiento de uniform. Cero escrituras en la textura, cero invalidaciones de mosaicos — la onda desapareció de raíz, no solo se mitigó.
El guardián: una diferencia de píxeles
No eliminamos el renderizador de Core Graphics. Lo convertimos en una implementación de referencia fuera de línea, y cada cambio de Metal debe pasar una comparación de píxeles contra é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 diferir legítimamente en unos cuantos niveles de gris a lo largo de bordes suavizados. El criterio es estructural: que no falte tinta, que no haya desplazamientos ni cambios de color y que no exista una diferencia grande lejos de la tinta.
![]()
Este arnés detectó cuatro de las cinco fallas que encontramos al construir el renderizador fuera de línea, todas diagnosticadas 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 ausencia 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 consistía en seis pasos que podían entregarse de forma independiente: un renderizador offline que pasara 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 la textura directamente; 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 comparación de capturas — que escribía, borraba, hacía zoom y arrastraba en el iPad Pro del escritorio.
El dispositivo detectó tres fallas que todas las comprobaciones automatizadas pasaron por alto. Por encima del 100 % de zoom, los trazos se dibujaban dos veces — textura suave debajo, sprites nítidos encima —, lo que se percibía como un desenfoque tenue que la persona notó en segundos. Al confirmar un trazo, la imagen parpadeaba durante un cuadro porque la superposición anterior hacía un fundido cruzado fuera de sincronía con la actualización de la textura. Y por encima del 170 % de zoom, todas las notas desaparecían: 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, esa es la razón por la que la persona sigue en el 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.
Segundo, no hace ingeniería de más. Este código funciona con reglas internas explícitas — no hay que agregar andamiaje de compatibilidad hacia atrás antes del lanzamiento, y la complejidad solo aparece cuando un dispositivo demuestra que es necesaria —, y K3 las sigue sin que haya que recordárselas. No agregó índices espaciales «para después», no envolvió cada llamada en comprobaciones defensivas ni creó abstracciones especulativas. Pedirle algo se siente como trabajar con un colega competente que leyó las reglas internas y realmente cree en ellas.
Tercero, 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 proactivamente la salida de su propio renderizador para diagnosticar los cinco bugs del arnés mencionados arriba. Tener la herramienta le recordó que debía mirar.
La otra mitad: K3 escribió la mayoría de las fallas de esta historia, incluido el del espacio de coordenadas que hacía desaparecer las notas. Sus límites son reales. El trabajo nunca fue seguro porque el modelo tuviera razón; 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 de ahora en adelante
Ya no queremos desarrollo agéntico basado en especificaciones grandes — el estilo de entregarle a un modelo una especificación extensa y aceptar lo que resulte. El modo de fallo no es el código malo; es el código que nadie entiende.
Lo que funcionó aquí y vamos a conservar: pasos pequeños, cada uno discutido antes de comenzar, 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 hecho para exactamente este ciclo: lo bastante rápido para mantenerlo ágil, lo bastante inteligente para mantener los pasos pequeños y limpios.
La onda desapareció, el pipeline es una sola idea en lugar de dos y el proceso que nos llevó hasta aquí se queda.