LavaUI
How LavaUI draws
A Swift UI framework for a Wayland desktop where applications have no renderer at all. They write draw commands into shared memory and stop. One process owns the GPU for the whole desktop.
exec to a mapped, sized windowStage one — the application
You write views, not draw calls
The authoring layer is what you'd expect if you've written SwiftUI: value-type views, state that invalidates what depends on it, modifiers that wrap rather than mutate. Layout is Flexbox, via Yoga.
struct Counter: View {
@State private var count = 0
var body: some View {
VStack(padding: 16, spacing: 8) {
Text("Tapped \(count) times")
Button("Tap") { count += 1 }
}
.background(Theme.current.panel)
.frame(width: .pct(100))
}
}
A body pass doesn't produce pixels or GPU state. It produces a flat array of draw commands — Rect, RoundedRect, Text, Image, Mesh, clip and blur scopes — written once, into memory another process is already mapped to.
Text is shaped on this side, and only on this side. The command carries positioned glyph indices; the renderer's whole job is resolving those to atlas rectangles. A drawn run therefore cannot drift from the run that was measured during layout, because they are the same run.
Stage two — the boundary
The draw arena
A draw list isn't a message. Its size isn't known when the frame starts, because it grows during emit as the tree turns out bigger than last time. Put that through a ring buffer and you either reserve a worst case every frame or copy the finished list in.
So the transport is a mapping, not a queue. The UI writes draw commands exactly once, directly into the memory the renderer will read them from.
Three slots, one producer, one consumer, no blocking in either direction. The producer writes a slot that is neither the one it published last nor the one the consumer says it holds — with three slots exactly one such slot always exists. A producer never waits on a slow renderer; a renderer never sees a frame torn out from under it.
Two details are worth more than they look:
- Every offset is derived, never transmitted. The consumer reads no pointer and no offset out of shared memory — only capacities, which it clamps. A hostile producer can lie about how much it wrote and still cannot make the reader compute an address outside the mapping.
- Growth needs no control channel. A frame that outgrows the arena can't be finished in it and can't be abandoned either, since emit is already half done. The producer creates a larger mapping, copies the partial frame across, carries on writing — and records the new generation in the old header, where the consumer is already looking.
A frame costs one write into memory that was already mapped. Not a serialize, not a copy, not a round trip.
Stage three — the renderer
One renderer for the whole desktop
Every Lava window on screen is drawn by the same Vulkan device, in the compositor. Applications initialise no graphics API, negotiate no swapchain, compile no pipelines, and hold no GPU memory.
Which is where the startup number comes from. 80 ms from exec to a mapped, correctly sized window — measured on a terminal emulator that also forks a shell and loads a font in that window. There is no device enumeration in the path, because the device came up once, with the desktop.
It pays elsewhere too. Glyph atlases and image atlases are per-desktop rather than per-process, so the second application to ask for a font pays nothing for it. And the effects that are genuinely expensive — backdrop blur, drop shadows as signed-distance falloff rather than image passes — are written once against one device instead of once per toolkit.
The retained half
An immediate-mode draw list has a ceiling: nothing can move without the application. Scroll, hover, a caret — each costs a wake, a full re-emit, and a round trip. Invisible with one client; on a desktop it's the difference between a window that scrolls and a window that is frozen.
So part of the list is retained. A node is a command with an identity, and identity is what lets the renderer keep state against it — scroll offset, hover amount, animation targets. The renderer scrolls the subtree and redraws without waking anyone. A renderer that ignored nodes entirely would draw exactly the same frame, which is what made them safe to add.
The whole path
Where all of it sits
Lava is a wlroots compositor, so foreign clients — GTK, Qt, anything X11 through XWayland — connect over ordinary Wayland and are composited the ordinary way. Lava's own applications take a second path alongside it, and the two meet in the scene graph.
The two Vulkan devices are the part people ask about. The canvas engine creates its own instance rather than borrowing wlroots' renderer, so it can allocate exactly the images it wants to export and keep its pipelines, atlases and blur targets under its own lifetime. The two only ever meet as a dma-buf handle plus a fence — the frame never leaves the GPU, and neither device ever waits on the other's queue.
The middle column isn't only for foreign toolkits, either. A Lava application started without client mode takes exactly that path instead — which is the subject of the next section.
Honestly
What it costs
Centralising the renderer centralises its failures: a bug in one draw path is a bug on every window, and a compositor that dies takes the session with it. The wire format is a real compatibility surface — a command kind means the same thing in both processes or nothing works, and both processes are versioned together for exactly that reason.
Two Vulkan devices on one GPU is a real cost too: the handoff has to be fenced correctly every frame, and a missed fence is a torn window rather than an error message.
Threads are not processes
Drawing is parallel. Each window gets its own worker in the frame group and leases its own VkQueue for its lifetime, so windows record and submit concurrently rather than queueing behind one another. Throughput was never the thing centralising costs you.
Containment is. A worker thread is not a sandbox: a runaway client's draw list is rasterised on the compositor's own pool, competing with every other window, and a GPU fault in one window's worker lands in the compositor's address space. Chromium spends an IPC hop and an extra copy per frame to get isolation and parallelism both. This buys the parallelism and not the isolation — which is the honest shape of the trade, and worth stating plainly rather than filing under performance.
It isn't all-or-nothing, though, and that's the part that rescues it. Client mode is a runtime choice, not an architecture. Start the same binary without LAVA_CLIENT=1 and the renderer initialises in the application's own process — its own Vulkan device, its own swapchain, an ordinary window like any other toolkit's. Same views, same layout, same draw list; it simply never crosses a process boundary.
So an application doing serious 3D, or anything that would rather own its frame pacing than share a thread pool, opts out and pays the ordinary price: no 80 ms start, no shared atlases, a graphics stack of its own. Everything else — a terminal, a settings panel, a dock — stays on the shared renderer, where those costs are exactly the ones worth avoiding. The desktop runs both at once, and the scene graph doesn't care which is which.
LavaUI is part of Lava, a Wayland desktop written in Swift and C++ — compositor, shell and applications in one tree.