← Otto Developer GuideRendering pipeline

This document explains how Otto gets from “some state changed” to “pixels on a display”, and where you would hook into that.

Files to follow along with:

The renderer and the render elements are the otto-render crate (crates/otto-render/). The compositor re-exports its modules under their old paths, so code in src/ still says crate::skia_renderer::SkiaRenderer.

The mental model

Otto is a retained-mode compositor wearing an immediate-mode compositor’s clothes.

Smithay expects a list of render elements per frame: “draw this texture at this rect, it damaged these regions”. That is an immediate-mode API. Otto satisfies it, but almost all of Otto’s UI (windows, the dock, exposé, the app switcher, shadows, blur, every animation) lives in a single retained tree managed by lay-rs, and is handed to Smithay as one element, the SceneElement.

The analogy: think of the scene graph as a document, and the frame as printing it. src/workspaces/ spends its time editing the document: moving a layer, changing an opacity, starting an animation. It never prints. Once a frame, the printer asks the document what changed, and re-inks only those parts of the page.

That is why so little of Otto looks like drawing code, and why “why is this not updating?” is nearly always a damage question rather than a drawing question.

The layers underneath

  1. Smithay owns the plumbing: a GlesRenderer, the output abstraction, swapchains, and the damage tracker. Conceptually it renders into a buffer that someone will later present.

  2. The backend decides what a buffer is.

    • winit: the output is a window inside another compositor; the buffer is presented into that host window.
    • udev/DRM: the output is a real connector/CRTC/plane pipeline; buffers are submitted to KMS.
  3. Otto wraps Smithay’s GL with Skia. SkiaRenderer takes the current EGL framebuffer, wraps it as a Skia surface, and draws into that canvas. So everything Otto paints is Skia, but the buffer management is Smithay’s.

  4. Otto draws through the scene graph. lay-rs owns the tree and its Taffy-based layout; SceneElement is the bridge into Smithay’s element list. The tree’s shape, and how subtrees of it become hardware planes, is The Scene Graph.

Frame flow

Render pipeline

  1. Build elements (src/render.rs) produces one OutputRenderElements list per output: the SceneElement, plus cursor and drag-and-drop surfaces, plus any debug overlays.
  2. Hand them to OutputDamageTracker. Smithay intersects each element’s damage with the age of the buffer about to be drawn into, and produces the set of rects that actually need repainting. If that set is empty, the frame is skipped entirely.
  3. Render the damaged regions. Smithay drives the pass; SkiaRenderer wraps the framebuffer and the SceneElement paints the scene into it.
  4. Present. The backend submits the buffer (a KMS atomic commit, or a host window swap).

On udev this is not the whole story: Otto also splits the scene into several scanout-capable buffers so the display hardware can composite them without the GPU. See DRM Planes.

Backends in practice

winit is best for day-to-day development. The output is a regular window. There is no hardware cursor plane, so the cursor is composited normally. It does not offer real outputs, dmabuf import, hardware planes, or the screenshare frame path, so anything touching those has to be tested on udev. Touch gestures are unsupported.

udev/DRM is the production path. Smithay manages connectors, CRTCs, planes, swapchains and submission. The cursor can be promoted to its own DRM plane; parts of the scene can be promoted to overlay planes.

x11 runs Otto as an X11 client. Basic and not actively maintained.

Sampling a client’s texture

A client’s buffer is drawn through a Skia shader in workspaces::utils::configure_surface_layer, and which filter that shader uses is chosen per surface by surface_filter. Bicubic (Catmull-Rom) is the general-purpose answer but costs ~12-16x nearest in the fragment shader, so a buffer that needs no resampling is copied instead.

Whether it needs resampling is a question about physical pixels, not about the layer. The scale and translation the gate looks at describe the texture’s mapping onto its layer; that only says what reaches the framebuffer if the layer itself starts on a whole physical pixel. Surface origins are logical values multiplied by the output scale, so on a fractional scale they land mid-pixel (logical 101 x 1.65 = 166.65) unless something rounds them.

Two rules keep that honest, and they only work together:

Dropping either one puts a 1:1 buffer through a point sample half a pixel off, which shows up as doubled and dropped rows of pixels across every window on a fractionally scaled output.

Clients that are not fractional-scale aware hand over an integer-scaled buffer (a 2x buffer on a 1.65x output, scale = 0.825) and take the bicubic path. That is a real resample and there is nothing to snap away.

otto-kit clients are fractional-scale aware. Every kit surface is drawn at the preferred_scale the compositor sends for it: a buffer round(logical × scale) pixels at buffer_scale = 1, shown at its logical size through a wp_viewport, or a plain buffer_scale = n at a whole scale. Either way the buffer reaches the output 1:1, which is what makes hinted and subpixel text worth drawing. A new preferred scale resizes the buffer on the surface’s next draw (BaseWaylandSurface::sync_scale) and repaints its whole layer tree; the app hears about it through App::on_scale_changed. The buffer scale and viewport describing a buffer of a new size go out only with that buffer (ScaledBuffer::flush, from the draw, just before the swap attaches it): the compositor checks them against the buffer a commit carries, and a crop or a move committed without a repaint in between would otherwise land state for a buffer that is not there yet, which kills the client.

Because the buffer is shown 1:1, a kit window renders subpixel (LCD) text for the panel under it: the Skia surface is wrapped with the PixelGeometry of the one output the surface is on, from wl_output.subpixel turned by the output’s transform (a 90° output makes horizontal stripes vertical; 180° reverses them). A surface on two outputs, on one that reports no layout, cropped or stretched by a viewport, or drawn at a whole scale a compositor without wp_viewporter shrinks gets Unknown, which is greyscale text. It is checked on every draw and the surface re-wrapped when it changes.

Sizes, not just origins

An origin on the grid is only half of a box. A size reaches a layer the same way a position does, as a logical integer multiplied by the output scale, so snapping the origin alone leaves the far edge fractional. The server-side titlebar is 34 logical points (WindowElement::DECORATION_HEIGHT), and 34 x 1.75 = 59.5: its bottom hairline paints across three physical rows with no fully covered one, and the client content that starts below it begins on a half pixel, which resamples the entire surface subtree under it.

workspaces::utils::snap_extent_px snaps the far edge rather than the extent on its own:

(origin + extent).round() - origin.round()

Rounding an origin and an extent independently is not equivalent and is wrong when the two round in opposite directions — origin 10.5 -> 11 with extent 9.5 -> 10 puts the edge at 21 instead of 20.

It is applied wherever a physical box is handed to a layer:

As with positions, an integer scale never leaves the grid and every one of these is a no-op there.

The wallpaper

The wallpaper follows the same rule as a client’s buffer, more strictly: it is never resampled per frame. otto_kit::wallpaper renders it once per output into a frame exactly the output’s size, and BackgroundView draws that frame 1:1. The contract is specs/wallpaper.md.

The tests that hold this: otto_kit::wallpaper’s tests check that a frame is the screen’s size in every mode, that 1:1 placements (an odd size difference in centre, the #361 sizes in fill) copy pixels exactly, that cover never leaves a half-pixel offset, and that a cropped paint equals a whole one; utils::resample’s check aliasing, linear light and sharpness against Skia’s single-pass cubic; tests/wallpaper.rs checks the screen against the frame pixel for pixel and a cropped repaint against a whole one. The Vulkan tests in crates/otto-render (vulkan_wallpaper_*, ignored) do the cropped-repaint check on a GPU, and report what the old half-pixel nearest path did there.

Getting frames out

Two independent capture paths exist, and they share one GPU blit:

Both are described in Screen Sharing.