← Otto Developer GuideSkia on Vulkan: plan

Status: phases 1 and 2 done on 2026-09-25, on the feat/vulkan-device branch of the smithay fork and try/smithay-latest in Otto. Phase 3 is implemented, passes its GPU tests and ships in the default build: the renderer is selectable at runtime, with Vulkan as the default and OpenGL as the fallback when Vulkan cannot start (see Phase 3 status).

Otto draws with Skia’s Ganesh backend on OpenGL ES through EGL. This plan moves the compositor’s rendering to Ganesh on Vulkan while keeping Smithay’s DRM compositor, the plane scanout machinery and the lay-rs scene graph as they are. Client-side rendering in otto-kit is out of scope.

Why

What exists today

PieceState
skia-safe 0.93.1Vulkan backend compiled in already: lay-rs enables all-linux, which includes vulkan, and skia-binaries ships that combination prebuilt. No ash dependency; handles are raw pointers plus a get_proc closure.
Smithay masterbackend/vulkan (instance, physical device, driver info) and allocator/vulkan (VulkanAllocator, dmabuf export with modifiers). No renderer.
Smithay PR #2165Draft Vulkan renderer, compute-shader based, todo!() in wait, filters, update_memory. Not usable as a renderer. Its backend/vulkan/{device,image,format}.rs layer (device + queue, dmabuf import with explicit modifier layouts, exportable images, timeline semaphore as drm syncobj) is the reusable part.
lay-rsBackend agnostic. The engine takes a DirectContext from the host or from the target canvas. GL-specific code is ~80 lines: renderer/skia_fbo.rs, the C API’s FBO entry point, one dead import in drawing/scene.rs, and the GL setup in 7 examples/tests.
Otto~2.6k lines fully GL-bound (skia_renderer.rs, renderer/*), plus typing coupling to GlesRenderer through Smithay’s GbmGlesBackend / MultiRenderer in udev/.

Gaps that shape the design:

Design

Device layer

Take Smithay master’s backend/vulkan plus the device/image/format layer from PR #2165 into the fork branch (nongio/smithay), independent of that PR’s renderer. That gives Otto:

PR #2165 requires the compute queue and STORAGE usage; the port changes that to the graphics queue and SAMPLED / COLOR_ATTACHMENT. This is the one place we diverge from upstream, and it should go back as review feedback on the PR rather than live in the fork for long.

SkiaVkRenderer in Otto

A new renderer next to the existing one, implementing the same Smithay traits SkiaRenderer implements today, without wrapping a GlesRenderer:

Sync

Vulkan orders semaphore operations by submission order on one queue, so fences can wrap Skia’s submits without touching its internals:

Allocation for scanout

Keep GbmAllocator for the primary swapchain and the plane swapchains in scene_dmabuf_element.rs, but pick modifiers from the intersection of the plane’s IN_FORMATS and the modifiers Vulkan reports for COLOR_ATTACHMENT. Everything else in the plane machinery (slot bind, damage, backdrop composite, UnderlyingStorage::Dmabuf) stays. Switching to VulkanAllocator is possible later since DrmCompositor is generic over the allocator, but it is not needed for the first cut.

lay-rs

Backends

Phases

  1. Device layer in the fork. Rebase Smithay PR #2165’s backend/vulkan commits onto the fork, switch to the graphics queue, add the SYNC_FD semaphore helpers. Unit test: import a GBM dmabuf, export an image, round-trip the modifier.
  2. Standalone probe. A small binary under examples/ that creates the Skia Vulkan context on the device, wraps a GBM buffer as a render target, draws with lay-rs, and reads pixels back. Proves skia-safe’s Vulkan wrapping and the fence wrapping without touching the compositor.
  3. SkiaVkRenderer behind a config flag. Implement the trait table above; run the full udev path with planes disabled first (planes_enabled false). Verify with /tmp/otto-dump-planes and the perf toggles. Screenshare and screencopy through BlitCurrentFrame.
  4. Planes and explicit sync. Slot binding as render targets, modifier intersection for plane swapchains, sync points from semaphores, syncobj release points. Verify zero-copy counts match the GL path on the Framework laptop.
  5. lay-rs cleanup and Vulkan test path.
  6. Decide on default. A/B GPU residency (the gpu_bench.py RC6 method), stability over a multi-day session, then flip the default or keep GL.

Phases 1 and 2 are independent of the Smithay master port that is in progress in otto-smithay-latest; phase 3 onwards should land on top of it so the renderer is written once against the current traits (Frame:: output_size, FrameContext, draw with cache).

What the probe established

examples/vulkan_probe.rs (Otto, try/smithay-latest) runs the whole chain on the Framework laptop’s Iris Xe with ANV:

Phase 3 status

The vulkan feature is on by default, so a stock build can run the udev backend on SkiaVkRenderer (crates/otto-render/src/renderer/vulkan/) or on the GL SkiaRenderer; the choice is made at startup. Vulkan is the default; otto --tty-udev --renderer gl selects GL, and so does renderer = "gl" under [rendering] in the config (Settings > General > Renderer writes that key); the command line wins. A build without the feature defaults to GL and refuses an explicit vulkan with an error naming the feature.

Before committing to Vulkan, run_selected probes the primary GPU (renderer::vulkan_api::probe: a 1.3 instance plus a physical device whose render or primary node is the GPU, the same lookup GbmVulkanBackend:: add_node makes). When that fails (no loader, no ICD, a driver below 1.3, a VM GPU) the session warns on otto::udev with the reason and runs on GL instead, and Settings reports Vulkan as unavailable on this machine through unavailable_choices. Without this a stored renderer = "vulkan" would exit at startup and the user would be looping at the greeter. Startup logs the choice (otto::udev: renderer: vulkan).

The udev backend is generic over RendererApi (crates/otto-render/src/renderer/active.rs): UdevData<A> and every impl Otto<UdevData<A>> are monomorphised once per renderer, and GlApi / VulkanApi supply the multi-GPU api, the device renderer, its texture and error types, the render-node lookup and whether planes may be used. The renderer-specific calls the backend makes (plane surfaces, render formats, the Skia context) go through the small SkiaDeviceRenderer trait both renderers implement, and the render elements draw into whatever frame they are given through FrameSurface. winit and x11 stay on the GL SkiaRenderer. The drawing itself lives once, in crates/otto-render/src/renderer/draw.rs and the elements’ canvas helpers, and both frames call it.

What works, covered by the ignored GPU tests (cargo test --features vulkan --lib -- --ignored vulkan):

Not done:

Risks