The Flock example in a GPUI window: a thousand pale blue birds wheeling away from a red hawk, a green dog with its herding ring, and a pen on the right

Desktop Apps on GPUI, Written in Osprey

By Christian Findlay 6 min read

GPUI is the UI framework Zed is built with. It is written in Rust, it draws everything with the GPU, and it is fast enough to hold a code editor at the display's refresh rate. Osprey can now use it. An Osprey application runs in a GPUI window through Osprey Native, the framework that already drives a SwiftUI app and the package manager inside VS Code.

The application is all Osprey: its state, its rules, its screens, every number it draws. A small Rust crate, hosts/gpui, opens the window and turns what Osprey returns into GPUI elements. It makes no decisions of its own.

Two examples run on it today, on macOS:

  • Flock: a herding game with a thousand boids, hawks that hunt them, and a dog you steer to drive them into a pen. A thousand birds ran at 119–120 fps on a 120 Hz M4 Max; 5,000 birds now cost 4.7 ms of the 8.3 ms a 120 Hz frame allows.
  • Painting: a port of GPUI's own examples/painting.rs. Captured side by side at the same window size, the Osprey port and the Rust original differ in zero pixels, both on first paint and after the same freehand strokes are drawn into each.

Two functions across the boundary

The Osprey compiler builds the application as a static library. The Rust host links it and calls two exported functions:

const char *start(const char *context);
const char *dispatch(const char *saved, const char *event);

Here is the painting port's whole boundary, from main.ospml:

extern gpui_slice (key : string) -> string

start source =
    handle ui::Slices
        fetch key => gpui_slice key
    Runtime::start (source, \context => App::initial context, \(before, after, frames, writes) => App::regions (before, after, frames, writes), \store => App::save store, App::theme ())

dispatch (saved, event) =
    handle ui::Slices
        fetch key => gpui_slice key
    Runtime::dispatch (saved, event, \(context, store, message) => App::update (context, store, message), \(before, after, frames, writes) => App::regions (before, after, frames, writes), \store => App::save store, \doc => App::restore doc)

Each call returns an envelope: an opaque session string for the host to pass back, the regions of the screen whose signals changed, any capability requests (read a file, start the frame clock) and diagnostics. The host draws the regions and sends back events by the ids Osprey issued. A button press reaches update as the typed action the button was built with. The host never sees an action, a score or a rule.

Because update is pure, the tests drive the real start/dispatch path with fixture events and check the envelopes. Flock has 13 tests and an osprey hunt harness over its rules. None of them needs a window.

Scenes are data

Buttons, text and pickers map to GPUI's controls. Drawing goes through a Scene node: a list of typed primitives (rectangles, discs, line strips, sprites, glyph runs and paths) that the host paints with GPUI's own painter. A path is the same sequence of builder calls GPUI's PathBuilder takes. Here is the painting example's star, with its Oklab gradient:

star () = fill [ui::MoveTo(toX = 350.0, toY = 200.0), ui::LineTo(toX = 370.0, toY = 260.0), ...] (ui::Linear(angle = 180.0, from = Scenes::hex (16436245, 1.0), fromAt = 0.7, to = Scenes::hex (13987084, 1.0), toAt = 1.0, oklab = true))

The encoder sends each primitive as one small array with a type code first, so the Rust painter reads it without a schema. For paths the host replays the steps on GPUI's builder in the same order and adds no call of its own. That is why the port is pixel-identical: GPUI tessellates the same commands it would have received from Rust. Getting there meant matching more than the shapes. Colours travel as float HSL or sRGB so no conversion rounds them, and the header layout has to match too, because GPUI batches paths by draw order and a different header changes how the pie's seams blend.

A frame clock, not a game loop

Flock animates by asking for the host's frame clock with an Animate capability. While it runs, every display refresh reaches update as a Frame message with the seconds since the last one. update moves the birds, and the field region refreshes like any other region. There is no simulation step in Rust.

Getting to the display rate took profiling. The first version spent 7.3 ms of Osprey time per frame at a thousand birds, and over half of a frame went to copying neighbour lists. osprey --profile pointed at the hot spots, and a sorted spatial grid brought a thousand birds to the display rate. Five thousand birds still cost 29 ms of dispatch a frame, for three reasons, each with its own fix:

  • The session round trip. Every call parsed the opaque state, restored the store, then saved and re-encoded it. A resident session keeps the Session in a file-scope cell of the application's boundary file, and Runtime::proceed takes and returns it as a value; the envelope's state is just the marker "resident".
  • The scene as JSON. Five thousand discs became five thousand JSON arrays, every float was spelled as text, and the host parsed 300 KB a frame. The scene stream sends each rect, disc and sprite to the host through one extern call instead, so the envelope is 1.7 KB.
  • Allocation in the inner loop. Each neighbour visit built a record. Each bird now steers by its first seven flockmates, the rule real starlings follow, in a walk that carries its sums as arguments and allocates once per bird.

The framework's own share of a 5,000-sprite frame went from 14.7 ms to under 1 ms. I also tried moving the steering onto Osprey's GPU compute surface. It was slower, because Metal emulates 64-bit floats, so it stayed on the CPU.

The framework names no host

Osprey Native was shaped by its first host, a SwiftUI app. Its icons were SF Symbols names, it had capabilities only that product used, and the platform was a string that could be macos or ios. Before a second native host inherited that, it all came out:

  • Icons are a typed Symbol union (Add, Delete, Warning, Package and so on). GPUI maps them to Lucide icons, VS Code to Codicons, a SwiftUI host to SF Symbols. Each host has a test that fails if any symbol has no icon.
  • The platform is a union: Macos, Linux, Windows, Ios, Android, Web. The GPUI host reports the operating system it actually runs on.
  • Work only one product can do is a HostCall in that host's namespace, not a framework capability.

GPUI is not GPU compute

Osprey already has a GPU compute surface: gpuMap, gpuFold and gpuMatMul compile your own Osprey kernels and run them on Metal or OpenCL. It is what runs GPT-2 on the GPU. That is raw access: your arithmetic runs on the device, the compiler proves each kernel pure, and the numbers come back to your program.

The GPUI host is something else. Your application is ordinary CPU code that returns a description of what to draw. GPUI's Rust renderer batches that description and rasterizes it with its own shaders. You write no shader and touch no device buffer, and nothing comes back except input events. Use compute for tensors and simulations, and GPUI for windows and 2D drawing. One program can use both: compute a field with gpuMap, read it back, draw it as a scene.

Try it

From a checkout of the repository, on an Apple silicon Mac with Rust installed:

make gpui-flock      # the herding game
make gpui-painting   # the painting port
make gpui-compare    # captures the port and the Rust original and diffs them
make gpui-test       # the Osprey suites, the host's tests and clippy, and both smoke runs

Linux and Windows are next. GPUI already runs on both, so the work is Osprey library targets for them and CI that runs the same smoke tests there. The plan, with its measured numbers, is plan 0036. The host's contract is spec 0046.