← Otto Developer GuideThe Vulkan Renderer

Otto draws with Skia. On the udev backend (a real session on a TTY) Skia can run on either of two GPU APIs:

Everything above the renderer is shared: the lay-rs scene graph, the render elements, damage tracking, Smithay’s DrmCompositor. The Vulkan renderer only changes how client buffers get into Skia, where Skia draws, and how the rest of the system learns that a frame is done. If you know Rendering, this page is the part below “Skia draws”.

For the reasoning behind the port, the phase plan and what the probe found, see Vulkan Renderer Plan.

Why Vulkan at all

Choosing the renderer

Vulkan is the default. To draw with OpenGL instead:

otto --tty-udev --renderer gl      # one session

or, to keep it:

[rendering]
renderer = "gl"   # "vulkan" is the default

Settings › General › Renderer writes the same key. The command line wins over the config. The vulkan Cargo feature is in the default feature set; a build without it defaults to OpenGL, and refuses an explicit --renderer vulkan with an error naming the feature.

Choosing the renderer

Before committing to Vulkan, udev::run_selected (src/udev/init.rs) probes the primary GPU with renderer::vulkan_api::probe: it needs a Vulkan 1.3 instance and a physical device whose render or primary node is that GPU. When the probe fails (no loader, no ICD, an old driver, a VM GPU) the session logs why on otto::udev and runs on GL instead, recording the reason with renderer::vulkan_api::set_vulkan_fallback, and Settings shows Vulkan as unavailable. This matters because a stored renderer = "vulkan" that simply exited would leave the user stuck at the greeter.

Startup logs one line naming the device, driver, render node, Vulkan version, format counts and whether sync files can be exported and imported:

Rendering with Skia on Vulkan: <device> (<driver>), render node <path>, Vulkan <version>, ...

How the udev backend holds two renderers

The udev backend is generic over a RendererApi (crates/otto-render/src/renderer/active.rs). UdevData<A> and every impl Otto<UdevData<A>> are compiled once per renderer, and main picks which one runs. There is no runtime dispatch in the render loop and no mixed mode: one session, one renderer.

GlApiVulkanApi
Smithay multi-GPU apiGbmGlesBackendGbmVulkanBackend (crates/otto-render/src/renderer/vulkan_api.rs)
Device rendererSkiaRendererSkiaVkRenderer
PLANES_SUPPORTEDtruefalse (see What is missing)
wl_drmofferednot offered; clients use linux-dmabuf

What the backend needs beyond Smithay’s traits (plane surfaces, render formats, the Skia context, flushing planes) goes through the small SkiaDeviceRenderer trait, which both renderers implement.

The drawing code exists once. Render elements draw into whatever frame they get through FrameSurface, and both SkiaFrame and SkiaVkFrame call the same helpers in crates/otto-render/src/renderer/draw.rs. A visual change to an element never needs a Vulkan-specific twin.

GbmVulkanBackend plays the part GbmGlesBackend plays for GL: one SkiaVkRenderer per DRM node, next to a GBM allocator for that node. The renderer is created when the node is added, so a GPU Vulkan cannot drive fails there with a named error rather than on the first frame.

Inside SkiaVkRenderer

FileWhat it holds
mod.rsThe renderer, its Smithay trait impls, imports, targets, blits
frame.rsSkiaVkFrame, the per-frame drawing handle
sync.rsSync files around Skia’s submits, the semaphore pool
texture.rsSkiaVkTexture, SkiaVkTarget, read-back mappings
retire.rsDeferred destruction of GPU memory Skia may still read
format.rsThe DRM fourcc ↔ Vulkan ↔ Skia colour-type table
tests.rsGPU tests, ignored unless asked for

Setting up

SkiaVkRenderer::new checks the physical device for six extensions (external memory fd, dma-buf, DRM format modifiers, external semaphore fd, foreign queue family, image format list), creates a Smithay Vulkan Device on the graphics queue, and builds a Skia Ganesh DirectContext on that device and queue. When the device supports samplerYcbcrConversion it is enabled, so YUV video buffers can be sampled.

The context is built in context.rs through the raw skia-bindings (layers::sb), not skia-safe’s BackendContext: Skia only samples YUV when fDeviceFeatures2 tells it the YCbCr feature is on, and skia-safe has no way to set that field. context.rs writes it at its offset in skgpu::VulkanBackendContext, with a compile-time check on the struct size, so a skia-safe upgrade that changes the layout fails the build instead of misreading features.

Skia is told the API version explicitly, capped at 1.3. A driver may report 1.4 while the instance is 1.3; without the cap Skia asks for 1.4 entry points the loader never hands out, and context creation fails without saying why.

The renderer then works out which formats it can sample (dmabuf_formats) and which it can render into (render_formats), keeping only fourccs that format.rs can map to Skia. Each alpha format also offers its X twin (XRGB8888 for ARGB8888), since Vulkan lists only the alpha one and XWayland’s depth-24 windows need the opaque one.

NV12 is sample-only. It imports with SAMPLED usage alone and is wrapped with a YcbcrConversionInfo, so Skia converts to RGB as it samples: BT.601 up to 576 lines and BT.709 above, limited range, chroma at the midpoint, filtered linearly where the modifier’s format features allow. Clients have no way to say how their YUV is encoded yet, so the size decides, as video players do for untagged video.

Buffers in a frame

Buffers in one frame

Client dmabufs import with VulkanImage::new_from_dmabuf, using the buffer’s explicit modifier. The VkImage is cached per buffer (keyed weakly, so the cache never keeps a dead buffer alive), but the Skia image around it is made fresh on every import. A re-import follows a client commit, after which the buffer belongs to the client again and Skia must not trust the layout it saw last time.

SHM buffers (and import_memory, used for the cursor) upload into a Skia-owned GPU texture. Each surface keeps its texture, so later commits only write their damaged rectangles.

Render targets are dmabufs too: the swapchain slots Smithay’s DrmCompositor allocates with GBM. Bind<Dmabuf> wraps one as a Skia render target in GENERAL layout and caches the wrapper. GENERAL rather than UNDEFINED matters: a slot has to keep its last frame’s pixels, or partial damage would repaint on top of garbage.

Offscreen targets (Offscreen::create_buffer) are plain Skia render targets that Skia allocates itself.

Handing a buffer back

Vulkan tracks who owns an image. The renderer uses QUEUE_FAMILY_FOREIGN_EXT for “someone outside this Vulkan device”: KMS, a client, another GPU. Before Skia can touch a dmabuf it has to acquire it from the foreign family; when it is done it has to release it back.

Skia does the acquire itself, because every wrapped image is described to it as foreign-owned in GENERAL layout. The release happens in flush_target: dmabuf targets are flushed with flush_surface_with_texture_state(GENERAL, QUEUE_FAMILY_FOREIGN_EXT), so after every frame, read-back or blit the buffer is back where KMS and other devices expect it. Forgetting this step shows up as garbage or stale content on screen, because Skia’s layout transitions and the scanout engine disagree.

Sampled client dmabufs are released too. Each import records the texture’s BackendTexture in sampled, and release_sampled runs between the flush and the submit of every frame: set_backend_texture_state(GENERAL, QUEUE_FAMILY_FOREIGN_EXT) on each live one. Skia skips images already in that state, so only the textures drawn this frame get a barrier, and the next draw acquires them again.

Sync

skia-safe exposes no semaphore API: there is no way to hand Skia a semaphore to wait on or signal. The renderer works around Skia instead of inside it, with its own submits placed right before or right after Skia’s.

Sync around Skia’s submit

Semaphores and fences are pooled in SyncPool. A semaphore can only be reused once its batch has run, which the fence submitted alongside it tells. Every signal also gets a serial number, and completed() reports the last serial the GPU has finished; the next section uses that.

Freeing memory safely

Skia wraps dmabuf images it does not own, so it cannot keep them alive, and the Skia image of a texture escapes the renderer: lay-rs holds it to draw the window, and recorded pictures reference it. A texture can be dropped by Smithay while a picture still points at it, or while the GPU has not yet run the frame that samples it.

So memory is never freed on drop. TextureBacking and dead render targets hand their memory to the renderer’s graveyard (retire.rs) together with the Skia handle that reads it. On each submit, Graveyard::reap:

  1. marks an entry once nothing but the graveyard holds its Skia handle, stamping it with the serial of the frame just submitted;
  2. destroys the entry once the GPU has completed that serial.

Plane slot images go through a similar queue (PlaneTextureRelease), drained in flush_planes_for_scanout after a CPU sync.

Copies and read-back

Smithay traitHow
ExportMem (screencopy, screenshots)Surface::read_pixels, then release the target again
Blitsnapshot of the source, drawn into the destination with BlendMode::Src
BlitCurrentFrame (screenshare)the same copy from the last bound target, fenced into the destination dmabuf

Testing

The GPU tests in crates/otto-render/src/renderer/vulkan/tests.rs need a real Vulkan device, so they are #[ignore]d and CI skips them:

cargo test --features vulkan --lib -- --ignored vulkan

They cover offscreen draw and read-back, a GBM dmabuf rendered and imported back, the X formats drawing opaque, an image-cached lay-rs mirror of a texture, a whole layer tree over an XRGB dmabuf, and plane surfaces sampling dmabuf textures.

On a live session, check the startup line on otto::udev (renderer: vulkan) and the device line above. If the log instead says Vulkan was requested but unavailable, the probe failed and the session is on GL.

What is missing

Where to look

QuestionFile
How is the renderer picked, and when does it fall back?src/udev/init.rs (run_selected), src/main.rs
What does the backend require of a renderer?crates/otto-render/src/renderer/active.rs
How are renderers created per GPU?crates/otto-render/src/renderer/vulkan_api.rs
Why is my buffer refused?crates/otto-render/src/renderer/vulkan/format.rs, then the cannot render into dmabuf warning in mod.rs
Why is a frame not fenced?crates/otto-render/src/renderer/vulkan/sync.rs
Why is a texture still alive?crates/otto-render/src/renderer/vulkan/retire.rs