← bitácora

Stage 1: píxeles en pantalla

Qué debía demostrar el shell

El 11 de mayo de 2026 el repositorio todavía se llamaba kompass. Se renombró a rumb esa misma tarde: Kompass International SA tiene la marca registrada en las clases que cubren software, así que el nombre no podía sostener una marca pública. Rumb es un término náutico catalán para el rumbo de la brújula, y mantiene el tema de la rosa de los vientos que los forks ya tenían — tramuntana, migjorn, ponent. El commit del renombrado tocó el nombre del crate, el binario, el struct de la aplicación y el título de la ventana, y nada más.

Lo que el código tenía que responder ese día era más estrecho que «¿podemos construir un navegador?». Era: ¿pueden un WebView de Servo y una aplicación GPUI convivir siquiera en el mismo proceso? Los dos son renderizadores. Los dos esperan gobernar un bucle de frames. Los dos quieren un contexto GPU. La API de embebido de Servo está escrita para una aplicación que lo embebe y que es dueña de una ventana y de un compositor; GPUI está escrito dando por hecho que él es el dueño de la ventana y del compositor. Stage 1 es la respuesta más pequeña posible a qué pasa cuando les pides a los dos que se equivoquen en eso.

Si no pueden compartir proceso sin que uno le quite la ventana al otro, no hay proyecto — y la forma más barata de averiguarlo es conectarlos por la vía más burda que produzca una imagen, y luego mirar la imagen. Stage 1 es esa vía.

Dos renderizadores, una ventana

Servo se crea a través de WindowRenderingContext::new_offscreen_only, un añadido de nuestro fork de Servo. Construye un contexto surfman de GPU real a partir del handle de display del host — render por hardware de verdad, con WebRender y todo — pero le entrega una superficie Generic: una superficie que nadie presenta, redimensiona ni compone nunca.

Esa única decisión es la que hace posible todo lo demás. GPUI sigue siendo dueño del drawable que la pantalla muestra de verdad. Servo pinta en un framebuffer que solo existe dentro del proceso. Ninguno de los dos lados tiene que negociar por la ventana, porque solo uno de ellos la toca. Todo lo construido desde entonces se apoya en esa separación — la exportación por dma-buf una semana después, un framebuffer por pestaña seis semanas más tarde.

host display handle shared — one connection, two contexts GPUI context visible drawable presented to the compositor Servo context Generic surface never presented exists so the context is legal offscreen framebuffer the only thing handed over

En la construcción se fuerza una preferencia de Servo: WebGL2, que viene desactivada por defecto. Sin ella three.js y cualquier otro sitio con WebGL2 no renderiza nada, y la diferencia entre «hemos dibujado una página» y «hemos dibujado una página con una escena 3D dentro» era casi todo el motivo de la prueba.

Leer, intercambiar, subir

El camino del frame era tan literal como suena. En cada tick de render: girar el bucle de eventos de Servo, decirle al WebView que pinte y luego llamar a read_to_image sobre el framebuffer offscreen. Eso saca el frame terminado de la memoria de la GPU y lo mete en memoria del sistema como un buffer de imagen corriente.

Servo lo entrega en RGBA. GPUI sube los buffers de frame en BGRA8. Así que el shell recorría el buffer intercambiando rojo y azul, píxel a píxel, frame a frame, y le pasaba el resultado a paint_image como si fuera un PNG que alguien hubiera cargado desde disco.

Cuenta las pasadas sobre el framebuffer y salen tres: la lectura desde la memoria de la GPU, el intercambio en la CPU y la subida de vuelta. Dos de ellas existen solo porque las dos mitades del stack no se ponen de acuerdo en el orden de los bytes. Las tres existen porque todavía no hay forma de que se refieran a la misma memoria.

Nada de eso es ingenioso, ni pretendía serlo. Es el primer movimiento estándar cuando puenteas dos renderizadores que no comparten textura: convierte los píxeles en bytes, porque los bytes van a cualquier parte.

Dónde se iba el tiempo

Un viaje de ida y vuelta completo por la CPU, una vez por frame, para unos píxeles que nunca habían salido de la tarjeta — y el coste escala con el área del viewport, así que el camino se degrada justo a medida que la ventana empieza a merecer la pena.

La cadencia lo empeoraba. El bucle lo gobernaba únicamente request_animation_frame; no había ningún camino desde «Servo ha terminado un frame» hasta «GPUI, dibuja ahora». Las actualizaciones en pantalla iban rezagadas en torno a 1 Hz.

Esa cifra es una observación anotada en el commit, no una medición. No hay perfilado, ni traza de tiempos de frame, ni ejecución de comparación, y nunca la hubo. No medimos el readback porque no pensábamos quedárnoslo — el commit que lo introdujo ya nombraba la condición de salida: stage 2 necesita una textura compartida real de GL a WGPU.

Qué no resolvía

Casi todo.

El redimensionado de la ventana no estaba conectado al motor. ServoEngine se construía con un tamaño fijo y nunca se le avisaba de uno nuevo, así que la página maquetaba una vez con las dimensiones iniciales y GPUI estiraba el resultado a lo que fuera que acabara midiendo la ventana. Las media queries de CSS se disparaban contra el viewport equivocado, para siempre.

A la página no llegaba ningún input. Ni clics, ni scroll, ni teclas, ni hover. Los eventos de GPUI aterrizaban en el elemento de imagen y ahí se quedaban. La entrada del backlog que se abrió para esto lo dice sin rodeos: la página se renderiza pero es de solo lectura, y el input es un subsistema aparte que necesita sus propios commits.

Y el build existía en exactamente una máquina. tramuntana, ponent y stylo eran todos dependencias por ruta que apuntaban a checkouts hermanos. Nadie más habría podido compilar esto.

Lo que sí tenía era una página en pantalla, dibujada por Servo, dentro de una ventana de GPUI. Esa era toda la pregunta, y la respuesta fue que sí. La siguiente pregunta era mejor.

GPU SYSTEM MEMORY Servo offscreen framebuffer GPUI paints read_to_image every frame copy, then R↔B swap upload as a texture a full round trip, per frame
El framebuffer de Servo hace un viaje de ida y vuelta por la memoria del sistema y un intercambio de color antes de que GPUI pueda dibujarlo.