← 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 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) — 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 — 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 — 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 — 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.

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 — 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.

Getting frames out

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

Both are described in Screen Sharing.