← Otto Developer GuideLayers

A layer is the unit Otto draws with — every window, shadow, menu, dock icon and wallpaper is one or more of them in the lay-rs scene. This page is about that unit: what it holds, what happens to it on a frame, and the few ideas that make the rest of the code read as obvious. For the shape of the tree the layers form, see The Scene Graph.

Anatomy

Layers

A layer is the unit; The Scene Graph is the tree they form, and its overview diagram shows how whole subtrees of them become hardware planes.

A Layer value is a handle, not the thing itself: an Arc<Engine>, a NodeRef into the scene arena, a Taffy node id, and an Arc<ModelLayer>. Cloning one is free and clones nothing — two Layers with the same id are the same layer. That is why Otto passes them into 'static closures and stores them in half a dozen structs without a second thought, and also why a stale handle to a removed layer is a real hazard (Engine::is_layer_alive exists for exactly that).

The ModelLayer behind the handle is a flat bag of properties (lay-rs/src/layers/layer/model.rs):

GroupProperties
Geometryposition, size, anchor_point, scale, rotation, display
Paintbackground_color, border_color, border_width, border_corner_radius, shadow_offset, shadow_radius, shadow_spread, shadow_color, shape
Compositionopacity, blend_mode, image_filter, color_filter, clip_content, clip_children
Contentdraw_content — a closure that paints into a Skia canvas
Identitykey, pointer_events

The right mental model is Core Animation’s CALayer. A layer does not know what it represents. It has no concept of “window” or “button”; it knows where it is, how it looks, and how to paint itself. All the meaning lives in Otto — in WindowView, DockView, WindowSelectorView — and those types are just code that edits layers.

One consequence is worth stating early, because it explains a lot of Otto’s structure: a layer with no drawables is free. No background, no border, no shadow, no content closure, normal blend mode, no clipping — lay-rs calls that a layout-only passthrough and skips both repaint and its own geometry damage (RenderLayer::is_layout_only_passthrough). Container layers such as workspaces_layer or a window’s content_layer cost a transform and nothing else, so Otto uses them freely to give subtrees something to move as a unit.

Two faces: the model and the render layer

Every layer exists twice, and keeping the two apart is the single most useful distinction on this page.

ModelLayer is what you write. It is the declared intent: “position is (120, 40)”, “opacity is 0.4”. Its values are Attribute<V> cells — thread-safe, cheap to read, each with a process-unique id.

RenderLayer is what gets drawn. It is derived, once per frame, by the engine from the model plus Taffy’s layout result plus the parent’s state (update_with_model_and_layout). It holds resolved things the model never mentions: bounds, local_transformed_bounds, global_transformed_bounds_with_children, a 4×4 transform relative to the root, premultiplied_opacity (this layer’s opacity times every ancestor’s), and visible.

The seam between them is where several recurring confusions live:

Retained, not immediate

Otto never draws a window. It sets a property on a layer, and the layer remembers. That is the retained-mode bargain, and it is chosen deliberately:

The price is real and shows up throughout Otto: there is now a second copy of the truth, and it has to be kept in sync with the Wayland state without inventing work. Writers into the scene are unconditional — set_position schedules a change without comparing the old value — so every path that mirrors surface state into layers has to be idempotent by hand. See the damage section of Render Loop for how configure_surface_layer hashes its inputs to avoid exactly that.

A change is a transaction

Setting a property does not mutate it in place. It builds a ModelChange — old value, new value, the RenderableFlags the change implies (NEEDS_LAYOUT, NEEDS_PAINT) — and schedules it on the engine:

layer.set_position(Point { x, y }, None);                       // applies now
layer.set_position(Point { x, y }, Some(Transition::ease_out(0.3)));  // animates

With no transition the model value is written immediately and the change is still scheduled, so the flags reach the node. With a transition, the model keeps its old value and an Animation interpolates from from to to across subsequent Engine::update(dt) calls. Animation is therefore a property of the change, not of a loop. Nothing in Otto ticks a counter to move a window; it states the destination and the feel.

Three details follow from how transactions are stored, and all three have bitten this codebase:

One in-flight transaction per property. schedule_change keys transactions by the attribute’s id and cancels any existing one for that value. A second set_position replaces the first — along with its on_finish handler, which is simply dropped. src/shell/xdg.rs carries a comment explaining that the fullscreen un-park deliberately hangs off the size animation rather than the position transaction, because a client commit repositioning the window mid-flight would otherwise strand it in the overlay plane forever.

Springs inherit velocity. When a spring change lands on a property that already has a running spring, the macro in lay-rs/src/engine/command.rs samples the current velocity and seeds the new spring with it. Interrupting a workspace swipe or a dock magnification feels continuous for free — no special-casing at the call site.

Changes can be grouped. layer.change_position(p) returns an unscheduled AnimatedNodeChange; a batch of them submitted through Engine::add_animated_changes(&changes, animation) shares one animation and therefore moves in lockstep. That is the mechanism behind otto-surface-style-unstable-v1’s transactions: the handlers in src/surface_style/handlers/ accumulate change_* values while a client transaction is open and submit them together on commit (Surface Style Protocol).

Callbacks hang off either end: on_start / on_update / on_finish on a transaction, on_animation_* on an animation. Otto drives the xdg-shell configure sequence of a fullscreen transition from an on_animation_update handler — the client is being resized by the same curve that moves the layer.

How a layer gets its content

A layer paints itself in a fixed order (lay-rs/src/drawing/layer.rs):

  1. Background — the shape filled with background_color. Under BlendMode::BackgroundBlur this is drawn with Skia’s Luminosity blend over the blurred backdrop, plus a faint noise image; that is the whole “frosted glass” material.
  2. Drop shadow — drawn outside the shape (ClipOp::Difference), which is why a leaked clip anywhere above erases it.
  3. Content — the draw closure, or the picture recorded from it.
  4. Border — stroked on the shape.

The closure is the interesting part:

Fn(&skia_safe::Canvas, f32, f32) -> skia_safe::Rect

Width and height come in; a damage rect in the layer’s own coordinates goes out. That return value is not decoration — it is how content that the engine cannot reason about reports what changed. A closure is also free to paint outside the layer’s bounds, and the engine records where it actually painted in RenderLayer::content_overflow so the ink outside the box still gets repainted.

Otto uses this in two distinct ways.

Compositor-drawn content — dock icons, the titlebar, the window shadow — is Skia drawing straight into the canvas, usually produced by a view (below).

Wayland surfaces get a closure too, and notably not an image. On commit, configure_surface_layer (src/workspaces/utils/mod.rs) installs a closure that looks the client’s texture up by id at paint time:

layer.set_draw_content(move |canvas, w, h| {
    let tex = crate::textures_storage::get(&draw_wvs.id)?;
    // …place per contents-gravity, convert buffer damage to layer coords…
    canvas.draw_image();
    damage
});

The indirection buys two things. The renderer imports buffers into textures_storage on its own schedule while the scene holds only an id, so re-installing the closure every commit does not invalidate the layer’s cached picture. And the closure is the one place that knows the mapping from buffer pixels to layer coordinates — buffer scale, the viewport crop, the contents gravity — so it is the only place that can convert the client’s buffer damage into a rect the engine can use.

When the damage source is outside the closure entirely, Layer::add_damage(rect) and set_damage(rect) hand the engine a layer-local rect directly. They are also what mark a layer’s followers for repaint.

Composition: what a parent does

Children are painted in child order — z-order is tree order, and add_sublayer / prepend_sublayer are how Otto restacks. Beyond order, a parent contributes exactly four things to its descendants:

set_hidden(true) is the blunt instrument, and it is not just a visibility flag: it sets Taffy display: none so the subtree stops participating in layout, and it invalidates the engine’s hit-test and traversal caches. A subtree parked hidden costs nothing — which is how Otto keeps every workspace’s windows in the tree at once.

Caching: the picture and the image

Two caches sit on every node, and they are not alternatives to each other.

Picture cache (picture_cached, on by default) records the layer’s drawing into a Skia Picture — a display list — and replays it. do_repaint re-records only when the node is flagged NEEDS_PAINT, when its size changed, or when there is no cache yet. Deliberately not on a move, a parent transform change, or an opacity change: the picture is recorded in the layer’s local space and opacity is applied by the replay paint, so a translate or a fade costs a replay rather than a re-rasterisation. Sliding a workspace full of windows re-runs no draw closure at all.

set_picture_cached(false) is therefore an opt-out, and Otto reaches for it where a layer’s content is a live re-render of something else — exposé mirrors, the workspace-selector background, the XWayland mirror — because there the cache would freeze a moving image.

Image cache (image_cached, off by default) rasterises a whole subtree into an offscreen GPU surface and composites the result. It is the right answer for expensive but static drawing: the window shadow and dock app icons use it. It is the wrong answer for anything that should show a live backdrop blur from below, because the subtree is composited from its own buffer.

content_opaque is a third, unrelated flag: a promise that the draw closure fills the layer’s bounds with opaque pixels. Occlusion culling only treats a layer as an occluder when opacity is 1, the blend mode is Normal, the shape is a rectangle with square corners, and this flag is set — which is why client surface layers and the wallpaper set it explicitly.

Getting these wrong is the usual reason something is stale or slow, and all three are visible per-node in the scene debugger.

Damage is something a layer reports

Damage in lay-rs is derived, not declared. For each node, update_node_single compares the previous RenderLayer snapshot with the fresh one and unions rectangles for what moved, resized, faded, appeared or disappeared; content damage comes from the draw closure’s return value (or a pending add_damage rect) mapped into global coordinates by the node’s transform. If nothing changed, the node returns an empty rect and the engine short-circuits.

Two behaviours are worth internalising:

The per-frame union is Engine::damage(); Engine::subtree_damage(root) answers the same question for one subtree, which is how the KMS plane path decides whether a plane needs re-rendering at all (DRM Planes).

Followers: one layer’s content, elsewhere

layer.as_content() turns a layer into a draw closure that re-renders that layer’s whole subtree wherever it is installed, and leader.add_follower_node(&follower) records the link so the leader’s repaints mark the follower. Together they are lay-rs’s mirroring primitive: exposé previews, the XWayland mirror, and the wallpaper reused as the exposé backdrop are all follower layers pointing at a live subtree.

The mirror is a rendering of the source, not a reference to it, so it can sit anywhere in the tree at any size with its own transform — and the source keeps being the only real copy. Two sharp edges come with that: as_content guards against recursion with a thread-local set (a follower may be a descendant of its leader), and repaint marking propagates from the leader node itself, never from its descendants — so a client commit deep inside a window’s surface tree does not mark the mirror. Exposé documents how Otto works around the second.

Views: state in, layer tree out

For anything more structured than a single node, Otto uses a lay-rs View: a hashable model plus a render function returning a LayerTree, mounted on a layer.

let view = View::new("window_shadow", model, Box::new(view_window_shadow));
view.mount_layer(shadow_layer.clone());
view.update_state(&new_model);   // hashes the model; no-op when unchanged

update_state hashes the state and returns without touching the tree when the hash matches. That is load-bearing, not an optimisation: update_decoration runs on every commit of a decorated window, and re-rendering unconditionally would rebuild the titlebar’s layers at the client’s frame rate. The dock, workspace selector, window selector, background and context menus are all views.

Pitfalls

Where to look

ConcernFile
The layer handle and its APIlay-rs/src/layers/layer/mod.rs
Property baglay-rs/src/layers/layer/model.rs
Resolved per-frame statelay-rs/src/layers/layer/render_layer.rs
Changes, transactions, the change_model! macrolay-rs/src/engine/command.rs
Per-node update, repaint and damagelay-rs/src/engine/stages/update_node.rs
Paint order for one layerlay-rs/src/drawing/layer.rs
Tree traversal, caches, blurlay-rs/src/drawing/scene.rs
Surface layer configuration and the draw closuresrc/workspaces/utils/mod.rs
Per-window layerssrc/workspaces/window_view/view.rs
Client-facing layer propertiessrc/surface_style/

Related: The Scene Graph · Rendering · Render Loop · DRM Planes · Exposé