Rendering Pipeline
This page explains how Guido renders widgets to the screen.
Pipeline Overview
Main loop (once per iteration):
1. init_pending_gpu() → Give any surface born this iteration a
render target and its first layout, at
this iteration's instant — which is what
an enter animation is seeded from
2. flush_bg_writes() → Drain queued background-thread signal writes
3. take_frame_request() → Check if a frame was requested
Per-surface rendering:
4. Dispatch events → Route input events (MouseMoves coalesced),
each one declaring when the compositor saw
it happen — not when this loop got to it
5. Frame-pacing gate → Return if the compositor hasn't shown the
previous frame yet (jobs stay queued)
6. set_frame_instant(now) → What time it is, for this whole frame:
everything below is asked about this one
moment rather than reading a clock of its
own (cleared again after step 14)
7. drain_pending_jobs() → Process jobs: unregister, advance
+ process_jobs() animations, reconcile children, mark dirty
8. Partial layout → Only dirty subtrees re-layout
9. Skip-frame check → Skip paint if root is clean
10. Tree::paint_widget() → Build render tree (Rc cache reuse for clean children)
11. flatten_root_into() → Flatten to draw commands (incremental for clean subtrees)
12. frame() + damage_buffer() → Re-arm the frame callback and report damage,
both BEFORE presenting
13. GPU rendering + present() → Instanced SDF shapes with HiDPI scaling;
present() commits the surface
14. cache_paint_results() → Rc-share rendered nodes into the cache,
clear needs_paint flags
Rendering is paced by the compositor’s wl_surface.frame callbacks: after each
present, a new callback is requested, and the surface won’t render again until
it fires. An animating surface renders exactly once per compositor frame; an
idle surface renders nothing and the loop sleeps.
Layout Pass
The main loop calls layout with screen constraints:
extern crate guido;
use guido::prelude::*;
fn main() {
let constraints = Constraints {
min_width: 0.0,
max_width: screen_width,
min_height: 0.0,
max_height: screen_height,
};
widget.layout(constraints);
;
}
Each widget:
- Calculates its preferred size within constraints
- Positions children (if any)
- Returns its final size
Paint Pass
After layout, widgets paint to the PaintContext:
fn paint(&self, ctx: &mut PaintContext) {
// Bounds come from the Tree — the single source of truth — and paint
// happens in LOCAL coordinates, the parent having placed the node.
let (tree, id) = (ctx.tree(), ctx.id());
let bounds = tree.get_bounds(id).unwrap_or_default();
let local = Rect::new(0.0, 0.0, bounds.width, bounds.height);
// Background and border are one command: a rounded rect carries its own
// border, shadow and gradient.
ctx.draw_rounded_rect(local, self.background, self.corner_radius);
// Paint children. One call each, and it is the framework's: it culls a
// child nobody can see, serves a clean one from its cache, places it, and
// opens its Paint scope.
for &child_id in self.children.iter() {
ctx.paint_child(child_id, &ChildPaintOptions::default());
}
}
PaintContext accumulates:
- Shapes - Rectangles, rounded rects, gradients, arcs
- Paths - Fills and strokes, tessellated once and kept
- Images - Raster and SVG sources
- Text - Glyphs for text rendering
- Overlay shapes - Ripples, effects on top of content
HiDPI Scaling
The renderer converts logical coordinates to physical pixels:
extern crate guido;
use guido::prelude::*;
fn main() {
let physical_x = logical_x * scale_factor;
let physical_y = logical_y * scale_factor;
;
}
Widgets work in logical coordinates; scaling is automatic.
SDF Rendering
Shapes use Signed Distance Field techniques:
// In shader
let dist = sdf_rounded_rect(uv, size, radius, k_value);
let alpha = smoothstep(0.0, -pixel_width, dist);
Benefits:
- Resolution-independent anti-aliasing
- Crisp edges at any scale
- Superellipse corner support
Render Order
Within a draw group, each kind of draw is one layer, in this order:
- Shapes - Container backgrounds, borders, arcs
- Paths - Filled and stroked paths
- Images - Image content
- Text - Text content
- Overlay - Ripple effects, state layer overlays
This ensures ripples appear on top of text. Something painted over a later layer — a background over an image, a box over a path — opens a new group where the two overlap, so the order the tree painted in is the order drawn.
Shape Types
Rounded Rectangle
struct RoundedRect {
bounds: Rect,
color: Color,
corner_radius: f32,
corner_curvature: f32, // K-value
}
Gradient
struct GradientRect {
bounds: Rect,
start_color: Color,
end_color: Color,
direction: GradientDirection,
}
Border
Rendered as SDF outline:
struct Border {
bounds: Rect,
width: f32,
color: Color,
corner_radius: f32,
}
Transform Handling
The render tree handles transforms hierarchically:
fn paint(&self, ctx: &mut PaintContext) {
// Get bounds from Tree (single source of truth)
let bounds = ctx.tree().get_bounds(ctx.id()).unwrap_or_default();
// Apply user transform (rotation, scale) if set
if !self.user_transform.is_identity() {
ctx.apply_transform_with_pivot(self.user_transform, self.pivot);
}
// Paint content in LOCAL coordinates (0,0 is widget origin)
let local_bounds = Rect::new(0.0, 0.0, bounds.width, bounds.height);
ctx.draw_rounded_rect(local_bounds, Color::BLUE, 8.0);
// Paint children. `paint_child` places each one from its bounds in the
// tree, so a parent that wants the ordinary placement writes nothing.
for &child_id in self.children.iter() {
ctx.paint_child(child_id, &ChildPaintOptions::default());
}
}
Transforms are inherited through the render tree hierarchy. Each node has a local transform that is composed with its parent’s world transform during tree flattening.
Text Rendering
Text uses the glyphon library:
- Text widget provides content and style
- Glyphon lays out glyphs
- Glyphs render from a texture atlas
- Correct blending with background
Clipping
Containers set a clip region for their content:
extern crate guido;
use guido::prelude::*;
fn main() {
let bounds = tree.get_bounds(id).unwrap_or_default();
let local_bounds = Rect::new(0.0, 0.0, bounds.width, bounds.height);
// Set clip for this node and all children (in local coordinates)
ctx.set_clip(local_bounds, self.corner_radius, self.corner_curvature);
// Set clip for the children only: what the node draws itself is not cut
ctx.set_children_clip(inside_border, inner_radius, self.corner_curvature);
// For overlay-only clipping (e.g., ripple effects)
ctx.set_overlay_clip(local_bounds, self.corner_radius, self.corner_curvature);
;
}
A container clips with the last of these, as CSS clips overflow at the padding box: its children stop inside its border — the box inset by the border width, each corner radius less it — while its own fill, border and shadow are not cut at all.
Clipping respects corner radius and curvature for proper rounded container clipping. Clip regions are inherited through the render tree and transformed along with their parent nodes.
A clip is kept as the shape it was declared, not as the box around it. The
flattener carries the declared rect and radii along with the transform that
places them, and the shader carries each fragment back into that space before
testing it. So Overflow::Hidden on a container rotated 45° clips its children
to a rounded square turned 45°, not to the 1.41× larger square around it.
A clip inside a clip is cut by both, exactly. Where one rounded rect is their intersection the two are merged into it; everywhere else — clips turned apart, corners that cross, curvatures that differ — the inner keeps its shape and the outer is tested beside it, so a scroller filling a rounded card keeps the card’s corners. Text is cut the same way: glyphon clips to an integer rectangle, so a text any clip would cut differently from that rectangle is drawn as a texture and cut in the shader instead.
Animation Advancement
Animations advance during per-surface job processing, before layout and paint,
so the frame being built always renders with up-to-date animation values. Each
advance pushes a continuation job (Animation plus a Paint or Layout
follow-up when the value changed), which schedules the next advancement.
Continuation jobs are throttled by the frame-pacing gate: while the compositor hasn’t shown the previous frame, they stay queued, so animations step once per displayed frame instead of once per loop iteration. Widgets like TextInput use this mechanism to drive cursor blinking.
Performance Notes
Vertex Buffer Reuse
PaintContext reuses buffers between frames:
extern crate guido;
use guido::prelude::*;
fn main() {
self.vertices.clear(); // Reuse allocation
self.indices.clear(); // Reuse allocation
;
}
Batching
Similar shapes batch together to reduce draw calls. Text renders in a single pass using the glyph atlas, and small images are packed onto shared atlas pages, so consecutive images on one page are a single draw call too.
Layout Optimization
The layout system includes several optimizations:
Relayout Boundaries: Widgets with fixed width and height are relayout boundaries. Layout changes inside don’t propagate to the parent, limiting recalculation scope. A change to the boundary itself, such as its width or whether it is visible, still lays its parent out, so its siblings move around it.
Layout Caching: Layout results are cached. The system uses reactive version tracking to detect when signals have changed. Layout only runs when:
- Constraints change
- Animations are active
- Reactive state (signals) update
Paint-Only Scrolling: Scroll is implemented as a transform operation during paint, not a layout change. When content scrolls:
- Scroll offset is stored as a transform
- Transform is applied during paint phase
- Children render at their original layout positions
- The transform shifts content visually
- Clip bounds are adjusted for correct clipping
This means scrolling doesn’t trigger layout, significantly reducing CPU overhead.
Paint Caching and Damage Regions
The rendering pipeline includes several optimizations to avoid redundant work:
Partial Paint: Each widget in the Tree has a needs_paint flag. When a widget’s
visual state changes (e.g., a signal update triggers a Paint job), the flag propagates
upward to ancestors. During paint, Container checks each child’s flag — clean children
reuse their cached RenderNode from the previous frame. The cache is Rc-shared with
the render tree: reusing an unmoved child is a refcount bump, and a moved child clones
only the node header (its subtree and draw commands stay shared).
Skip Frame: If no widget needs paint after job processing and layout, the entire paint→flatten→render cycle is skipped for that surface.
Damage Regions: As widgets are marked for paint, their surface-relative bounds are
accumulated into a per-surface DamageRegion (None, Partial, or Full). The damage is
set as pending Wayland state before presenting — present() commits it together with
the new buffer — allowing the compositor to optimize its own compositing.
Incremental Flatten: The flattener caches its output per RenderNode. Clean subtrees
(where repainted == false) reuse their cached flattened commands with a translation
offset, avoiding the cost of recursing into unchanged subtrees.