← 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
- Explicit sync end to end. Vulkan timeline semaphores and sync files replace
the EGL fence plus
glFlushdance inrenderer/frame.rs, and the CPU wait inflush_planes_for_scanoutthat exists because Mesa attaches no implicit fence to EGLImage render targets. - One import path for client buffers. A dmabuf becomes a
VkImagewith an explicit DRM format modifier; no EGLImage, no external-texture blit fallback, nowl_drm. - Formats. fp16 and 10-bit render targets, needed by the HDR branch, are ordinary Vulkan formats; the GL path depends on EGL config luck.
- Smithay’s own renderer work is heading the same way (PR #2165), so a Vulkan device layer will exist upstream to share.
What exists today
| Piece | State |
|---|---|
| skia-safe 0.93.1 | Vulkan 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 master | backend/vulkan (instance, physical device, driver info) and allocator/vulkan (VulkanAllocator, dmabuf export with modifiers). No renderer. |
| Smithay PR #2165 | Draft 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-rs | Backend 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:
- skia-safe exposes no semaphore API.
FlushInfo’s signal semaphores are private and there is noDirectContext::wait. Sync has to happen around Skia’s submits, not inside them. - Skia treats a
VkImagecreated withDRM_FORMAT_MODIFIER_EXTtiling as an external texture: sample only, no mipmaps, no render-to. Wrapped render targets are not tiling-checked. So client buffers import as textures and plane slots wrap as render targets, which matches how Otto uses them. - Smithay’s multi-GPU layer is GLES only. A Vulkan renderer needs its own
GraphicsApior a single-GPU first phase.
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:
InstancewithVK_KHR_external_memory_capabilities,VK_KHR_external_semaphore_capabilities.Deviceon the graphics queue of the primary render node, withVK_KHR_external_memory_fd,VK_EXT_external_memory_dma_buf,VK_EXT_image_drm_format_modifier,VK_EXT_queue_family_foreign,VK_KHR_external_semaphore_fd,VK_KHR_timeline_semaphore.VulkanImage::new_from_dmabuf(explicit plane layouts) andnew_exportable(modifier list, dedicated allocation, fd export).- Format tables fourcc ↔
vk::Format, modifier queries per usage.
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:
DirectContextfromgpu::vk::BackendContext::new_with_extensionson the shared device, passing the device extension list so Skia enables DRM modifier support.RendererSuper:TextureId = SkiaVkTexture(aVulkanImageplus the SkiaImagewrapping it),Framebuffer = SkiaVkTarget(aVulkanImageplus the SkiaSurface),Frame = SkiaFramereused as is, since it only draws through Skia.ImportDma: dmabuf →VulkanImage::new_from_dmabuf→ImageInfowithAlloc::from_device_memory,QUEUE_FAMILY_FOREIGN_EXT→backend_textures::make_vk→Image::from_texture. Cached perWeakDmabuflike today; Skia handles the foreign-queue acquire barrier.ImportMem/ SHM: rasterImage→new_texture_image(ctx). NoVK_EXT_host_image_copyrequirement.Bind<Dmabuf>: slot dmabuf →VulkanImage::new_from_dmabufwithCOLOR_ATTACHMENT | TRANSFER_SRC | TRANSFER_DST→backend_render_targets::make_vk→wrap_backend_render_target,SurfaceOrigin::TopLeft. Replacescreate_surface_from_dmabufandPlaneTextureRelease.Offscreen:gpu::surfaces::render_target, already what backdrops use.ExportMem:Surface::read_pixels, unchanged.Blit:Surface::drawof a snapshot, orvkCmdBlitImagein an empty submit; the former is simpler and Skia-ordered.BlitCurrentFramefor screenshare: snapshot + draw.
Sync
Vulkan orders semaphore operations by submission order on one queue, so fences can wrap Skia’s submits without touching its internals:
- Acquire: for each client buffer with a drm syncobj point, export a sync
file (
DrmSyncPoint::export_sync_file), import it as a binary semaphore withSYNC_FD(temporary import), and wait on it in an emptyvkQueueSubmitbefore Skia’sflush_and_submit. Buffers without explicit sync useDMA_BUF_IOCTL_EXPORT_SYNC_FILEthe same way. - Release: after
flush_and_submit, an empty submit signals aSYNC_FDexportable semaphore. The fd becomes the SmithaySyncPointfor the frame (KMSIN_FENCE_FD, syncobj release points, screencast fences). This replacesSkiaSync(EGLFence), the trailingglFlush, and the CPU sync inflush_planes_for_scanout. - Fallback for drivers without
SYNC_FDsemaphores:vkQueueWaitIdlebehind a capability flag, so the path still works on odd hardware.
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
- Delete the unused
gl::direct_contextsimport indrawing/scene.rs. - Move
renderer/skia_fbo.rsand themake_glfallback behind aglfeature; movegl-rsto dev-dependencies. - Add
renderer/skia_vk.rs: wrap a hostVkImageas a render target withSurfaceOrigin::TopLeft. Used by examples and a headless Vulkan variant oftests/hdr_intermediates.rs(the only test exercising the GPU image-cache path). - No engine changes. Subtree buffers, backdrop images, picture and image
caches already go through backend-agnostic
surfaces::render_targetand snapshots.
Backends
- udev:
renderer = "vulkan"in config selectsSkiaVkRenderer, single GPU, noMultiRenderer.UdevRendererbecomes generic over the two renderers, or the GL path keepsMultiRendererand the Vulkan path uses the renderer directly. Multi-GPU on Vulkan is a later phase with its own SmithayGraphicsApi. - winit: stays on GL. Vulkan development happens on
--tty-udevand on the headless feature. A Vulkan winit path (Wayland WSI swapchain) is optional later work. - x11: unchanged, low priority.
- headless: no GPU, unchanged.
Phases
- Device layer in the fork. Rebase Smithay PR #2165’s
backend/vulkancommits onto the fork, switch to the graphics queue, add theSYNC_FDsemaphore helpers. Unit test: import a GBM dmabuf, export an image, round-trip the modifier. - 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. SkiaVkRendererbehind a config flag. Implement the trait table above; run the full udev path with planes disabled first (planes_enabledfalse). Verify with/tmp/otto-dump-planesand the perf toggles. Screenshare and screencopy throughBlitCurrentFrame.- 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.
- lay-rs cleanup and Vulkan test path.
- Decide on default. A/B GPU residency (the
gpu_bench.pyRC6 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:
- Smithay’s
Deviceon the graphics queue, SkiaDirectContexton it, a lay-rs scene rendered into an exportableVkImage, exported as a linear dmabuf, re-imported as a sampled texture and drawn again: pixel-exact, under 100 ms end to end in a debug build including PNG encoding. Alloc::default()is fine for wrapped images; Skia never touches the memory of an image it did not allocate.- The empty-submit fence works: a
SYNC_FDsemaphore signalled afterflush_and_submitbecomes readable once the scene has rendered. - Skia must be told
set_max_api_version(1.3). The device reports 1.4, the instance is capped at 1.3, and without the pin Skia asks for 1.4 core entry points that the loader does not hand out and context creation fails silently. - Three fixes to PR #2165’s device layer, now on the fork branch: dmabuf
imports use
DRM_FORMAT_MODIFIER_EXTtiling (the draft left themOPTIMAL, which produced Y-tiled reads of linear data and a memory requirement larger than the buffer), the memory type test usedbits & iinstead ofbits & (1 << i), and imports now intersect withvkGetMemoryFdPropertiesKHR. TheTextureimpl forVulkanImagemoved into the device layer sobackend_vulkanbuilds withoutrenderer_vulkan, and the syncobj timeline types moved tobackend::drm::syncwith re-exports at the old path.
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):
GbmVulkanBackend(crates/otto-render/src/renderer/vulkan_api.rs) is the SmithayGraphicsApi: one renderer per DRM node, on the physical device whose render or primary node matches, created when the node is added so a device Vulkan cannot drive failsdevice_addedwith a named error. Startup logs one line naming the Vulkan device, driver and render node.- Frames draw into wrapped dmabufs (
Bind<Dmabuf>,GENERALlayout, foreign queue family) and Skia-owned offscreen targets. A swapchain slot keeps its content between frames, so partial damage works. - Client dmabufs import with their explicit modifier; the Skia image is
re-wrapped on each import. SHM buffers upload into a per-surface texture
that later commits update in place;
import_memory/update_memoryback the cursor and other memory buffers. finish()returns a sync file from an empty submit after Skia’s; it goes to KMS as the in-fence.wait()imports a client’s sync file as a temporary semaphore payload, or waits on the CPU.ExportMem,BlitandBlitCurrentFramefor screencopy and screenshare. The screenshare copy attaches its fence to the destination dmabuf (DMA_BUF_IOCTL_IMPORT_SYNC_FILE), or waits when the kernel cannot.- Dropped textures and targets retire their memory until nothing holds their Skia image and the GPU has finished the frames that used it.
wl_drmis not offered:MultiRenderergetsImportEglthrough theEglImportUnsupportedmarker added on the smithay fork, which reports it as unsupported, so clients uselinux-dmabuf.
Not done:
- Plane scanout.
VulkanApi::PLANES_SUPPORTEDis false, so every output composites a singleSceneElement. The plane code builds for the Vulkan renderer (create_surface_from_dmabuf,flush_planes_for_scanout) but is untested; phase 4. - Modifier intersection for the primary swapchain beyond what Smithay’s
DrmCompositoralready does with the renderer’s colour-attachment formats. - Texture filters and debug flags are stored and ignored, as on GL.
- YUV beyond single-object NV12: disjoint planes, P010, and encodings other than the BT.601 (SD) / BT.709 (HD) limited-range guess.
Risks
- Driver coverage. ANV and RADV have the full extension set. NVK and
the proprietary NVIDIA driver need checking for
SYNC_FDsemaphore export andDRM_FORMAT_MODIFIER_EXTon imported images. Keep GL as the fallback until both are verified. - Compressed modifiers. Intel CCS and AMD DCC modifiers may be importable as textures but not usable as colour attachments. The modifier intersection handles plane slots; client buffers are only sampled, so this affects nothing else.
- Disjoint multi-plane dmabufs. PR #2165 imports single-memory-object
buffers only. Video clients (NV12 from VAAPI) commonly produce disjoint
planes; support needs
BindImagePlaneMemoryInfo. Track as a follow-up, keep those clients composited until then. - Skia layout tracking. Skia tracks
VkImageLayoutper wrapped image; after every external use (scanout, screencopy) the layout must be reported back withset_vk_image_layout, or Skia issues wrong barriers. - Two contexts in one process. GL and Vulkan Skia contexts can coexist (the binary already links both), but images do not cross between them. No mixed mode; the config flag selects one renderer per session.