Stage 1: pixels on screen
What the shell had to prove
On 11 May 2026 the repository was still called kompass. It was renamed to rumb the same evening: Kompass International SA holds the trademark in the classes that cover software, so the name could not carry a public brand. Rumb is a Catalan nautical term for a compass bearing, which keeps the wind-rose theme the forks already had — tramuntana, migjorn, ponent. The rename commit touched the crate name, the binary, the app struct and the window title, and nothing else.
What the code had to answer that day was narrower than “can we build a browser”. It was: can a Servo WebView and a GPUI application live in the same process at all? Both are renderers. Both expect to drive a frame loop. Both want a GPU context. Servo's embedding API is written for an embedder that owns a window and a compositor; GPUI is written on the assumption that it owns the window and the compositor. Stage 1 is the smallest possible answer to what happens when you ask both of them to be wrong about that.
If they cannot share a process without one taking the window away from the other, there is no project — and the cheapest way to find out is to wire them together by the crudest route that produces a picture, then look at the picture. Stage 1 is that route.
Two renderers, one window
Servo is created through WindowRenderingContext::new_offscreen_only, an addition in our Servo fork. It builds a real GPU surfman context from the host's display handle — genuine hardware rendering, WebRender and all — but hands it a Generic surface: a surface nobody ever presents, resizes or composites.
That single choice is what makes the rest possible. GPUI keeps owning the drawable the screen actually shows. Servo paints into a framebuffer that exists only inside the process. Neither side has to negotiate for the window, because only one of them ever touches it. Everything built since sits on top of that split — the dma-buf export a week later, one framebuffer per tab six weeks after that.
One Servo preference is forced on at construction: WebGL2, which ships defaulted off. Without it three.js and every other WebGL2 site renders nothing, and the difference between “we drew a page” and “we drew a page with a 3D scene in it” was most of what the test was for.
Read, swap, upload
The frame path was as literal as it sounds. Each render tick: spin Servo's event loop, tell the WebView to paint, then call read_to_image on the offscreen framebuffer. That pulls the finished frame out of GPU memory and into system memory as an ordinary image buffer.
Servo hands it over as RGBA. GPUI uploads frame buffers as BGRA8. So the shell walked the buffer and swapped red and blue, every pixel, every frame, and passed the result to paint_image as if it were a PNG somebody had loaded off disk.
Count the passes over the framebuffer and there are three: the read out of GPU memory, the swap across the CPU, the upload back. Two of them exist only because the two halves of the stack disagree about byte order. All three exist because there is no way, yet, for them to refer to the same memory.
Nothing about that is clever and none of it was meant to be. It is the standard first move when bridging two renderers that do not share a texture: turn the pixels into bytes, because bytes go anywhere.
Where the time went
A full round trip through the CPU, once per frame, for pixels that had never left the card — and the cost scales with viewport area, so the path degrades exactly as the window becomes worth using.
Pacing made it worse. The loop was driven only by request_animation_frame; there was no path from “Servo finished a frame” to “GPUI, draw now”. On-screen updates lagged around 1 Hz.
That figure is an observation written down in the commit, not a measurement. There is no profile, no frame-time trace, no comparison run, and there never was one. We did not measure the readback because we did not intend to keep it — the commit that introduced it already named the exit condition: stage 2 needs a real GL-to-WGPU shared texture.
What it did not solve
Almost everything.
Window resize was not wired to the engine. ServoEngine was constructed at a fixed size and never told about a new one, so the page laid out once at the initial dimensions and GPUI stretched the result to whatever the window became. CSS media queries fired against the wrong viewport, permanently.
No input reached the page. Not clicks, not scroll, not keys, not hover. GPUI's events landed on the image element and stopped there. The backlog entry opened for it says so flatly: the page renders but is read-only, and input is its own subsystem that needs its own commits.
And the build existed on exactly one machine. tramuntana, ponent and stylo were all path dependencies pointing at sibling checkouts. Nobody else could have compiled this.
What it did have was a page on screen, drawn by Servo, inside a GPUI window. That was the whole question, and the answer was yes. The next question was better.