-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture Render Pipeline
Covers
rosace-layout(Layer 3),rosace-render(Layer 4), androsace-compositor(Layer 0 — norosace-*deps, despite being consumed at the top of the stack). This is the second half of the frame pipeline in README.md; core.md covers what feeds into it (theElementtree) and state-and-reactivity.md covers what triggers it.
Once rosace-core decides which components to rebuild, rosace-layout measures and places the resulting widget tree, rosace-render turns that into a flat list of draw commands and rasterizes (or GPU-batches) them onto pixel buffers, and rosace-compositor uploads those buffers as textures and blends them onto the screen.
Think of it as three independent departments that only talk through plain data, never through shared mutable state: layout hands paint a set of (size, position) pairs; paint hands the compositor one or more RGBA pixel buffers (or, in GPU-shapes mode, a list of pre-batched draw items); the compositor's only job is uploading and blending rectangles of pixels. None of the three needs to know why a frame happened — that's rosace-core/rosace-state's job.
graph LR
subgraph "rosace-layout (L3)"
C["Constraints (top-down)"] --> M["measure — Flexure::layout_leaf / Flex/Grid/Wrap"]
M --> R["LayoutResult: size + child_positions"]
end
subgraph "rosace-render (L4)"
R --> W["walk_element paints widgets → DrawCommand stream"]
W --> PR["PictureRecorder → Picture"]
PR --> PP["SkiaCanvas::play_picture"]
PP -->|CPU mode| RAST["tiny-skia rasterizes to RGBA pixmap"]
PP -->|GPU-shapes mode| ITEMS["ordered CanvasFrameItems: Shader / Segment / Glyphs / Image"]
end
subgraph "rosace-compositor (L0)"
RAST --> UP["upload as texture"]
ITEMS --> UP
UP --> BL["blend N layers: base REPLACE, overlays ALPHA_BLEND"]
BL --> PRESENT["present to the wgpu surface"]
end
1. Layout runs on Constraints, top-down then bottom-up. Constraints (re-exported from rosace-core) carries min_width/max_width/min_height/max_height with an AxisBound of Bounded(f32) | Unbounded | Shrink per axis (D014's three-pass model: measure top-down, place bottom-up, paint). Flexure::layout_leaf is the base case — clamp a natural size into the constraints and return an empty-children LayoutResult. Container layout (Column/Row/Grid/Wrap) lives under rosace-layout/src/widgets/ and calls back into Flexure-style measurement per child.
2. Width/Height are the declarative sizing vocabulary. Fixed, Fill, Shrink, Fraction(f32), Min/Max/Range each convert to an AxisBound given the parent's available space — Fraction is explicitly of available space, not the screen (D015). MainAxisAlignment/CrossAxisAlignment (D017's baseline alignment, SpaceBetween/SpaceAround/SpaceEvenly) drive how layout_row/layout_column distribute the resolved sizes.
3. Paint is a tree walk that never writes pixels directly. Widgets don't touch a pixel buffer — they push DrawCommand values (FillRect, FillRRect, FillCircle, FillGradient, FillArc, DrawText, DrawShadow, BlitRgba, PushClip/PopClip, BackdropBlur, ShaderFill) into a PictureRecorder, which seals into an immutable Picture — a plain Vec<DrawCommand>. This is what makes replay-without-repaint possible: scrolling, animation frames, and Hero-transition morphs (DrawCommand::morph, remapping a captured Picture from one rect to another) all replay a Picture instead of re-running any widget's paint logic.
4. SkiaCanvas::play_picture is where a Picture becomes pixels — in one of two modes. SkiaCanvas is backed by tiny-skia (a pure-Rust CPU rasterizer, D032's "Skia" decision realized as tiny-skia rather than skia-safe). Two modes coexist on the same type today:
-
CPU mode (default): every
DrawCommandrasterizes straight into the canvas'sPixmapviatiny-skiacalls — anti-aliased rounded-rect fills, kerned text blitting, Gaussian-approximated shadows, clip masks. -
GPU-shapes mode (
set_gpu_shapes(true), D109/Phase 27): the eight built-in shape commands divert to registered SDF pipelines instead oftiny-skia, andplay_picturepartitions the command stream into orderedCanvasFrameItems —Shader(a GPU quad),Segment(a CPU-rasterized bbox cut from the scratch pixmap, for anything without a GPU pipeline yet),Glyphs(a batch of atlas-cached glyph quads), orImage. Ordering is preserved so a shape→text→shape stack still z-orders correctly even though the three item kinds execute through different GPU paths. GPU-shapes mode is enabled per-canvas by the platform, only where aGpuPresenterexists.
5. Text goes through one shared glyph-placement walk. layout_glyphs computes kerning, baseline, and bearing once and is used by both the CPU blit path and the GPU atlas-collect path, specifically so the two can't drift glyph-for-glyph. FontCache (D019: fontdue rasterization) caches each distinct glyph rasterization once (CachedGlyph = Arc<(fontdue::Metrics, Vec<u8>)>), keyed stably by px_bits << 32 | char << 1 | wants_bold — the same key the GPU atlas uses to avoid re-uploading a glyph it's already seen. Color emoji glyphs (ColorGlyph) skip the coverage-mask path entirely and reuse the RGBA blit pipeline instead of a second atlas page.
6. HiDPI is a single scale factor applied at replay time, never baked into recorded commands. SkiaCanvas stores a scale (device pixel ratio); every DrawCommand is recorded in logical pixels and play_picture multiplies by scale when it actually draws, so the same recorded Picture is correct whether replayed on a 1x or 2x (Retina) canvas. SkiaCanvas::new_hidpi(phys_width, phys_height, scale) is the HiDPI constructor.
7. Frame-skip and damage-rect are two separate optimizations layered on top of the same walk. In FrameEngine::paint: needs_paint (global-dirty OR any dirty component OR a fresh/resized canvas OR a forced hover repaint) gates whether painting happens at all this frame — a fully idle frame skips build, walk, and rasterization entirely. When painting does happen, full_repaint (global-dirty OR resize OR fresh canvas OR GPU-shapes mode, which is always a full repaint since damage-scoped clearing is a CPU-buffer economy that doesn't apply to a re-expressed ordered-item list) decides between clearing the whole canvas versus clearing and replaying only the damaged region — the union of old/new rects for every repainted widget, accumulated by walk_element in rosace/src/lib.rs and inflated by 24px on each side to cover pixels a widget paints outside its own layout rect (shadows, focus rings).
8. rosace-compositor only ever sees pixel buffers and quads — never widgets. GpuPresenter is wgpu-backed (D072: wgpu over raw Vulkan/Metal/DX12, so the right native backend is picked per-OS at runtime) and is a Layer 0 crate with a hard "zero rosace-* deps" contract — it takes CompositorLayers (raw pixel slices + width/height/opacity/dirty flag), ShaderQuads, AtlasGlyphs, and ImageQuads, all primitive types. rosace-platform is the only thing that imports both rosace-compositor and the typed shader/widget crates, and converts between them at that boundary. present_layers composites bottom-to-top: the base layer with REPLACE blend, every overlay layer with Porter-Duff ALPHA_BLENDING over it (D076).
-
GPU texture caching (D089, landed Phase 20): each layer slot holds a persistent
wgpu::Texture+ bind group across frames. A clean, unmoved layer re-uses its texture untouched (nowrite_texturecall); an offset-only change (scroll) is a cheap uniform buffer write; a frame where every layer is clean skips presenting entirely. Dirtiness flows fromSkiaCanvas::frame_dirty(set whenever the frame loop actually repaints that canvas) through the platform intoCompositorLayer::tracked. -
Placed/scroll layers (D090): a
CompositorLayer::placed(...)positions a quad at a screen-space rect and samples a content-sized texture at a UV offset — this is the mechanism behind state-and-reactivity.md's "scroll produces no CPU paint" (rosace_state::scroll_offsetfeeds the UV offset directly; the content texture is untouched).
-
Constraints/AxisBound— the top-down sizing contract every widget's layout receives. -
Flexure— the leaf-case layout primitive; container layout (Flex/Grid/Wrap) lives inrosace-layout/src/widgets/. -
LayoutResult— resolvedsize+ per-childPointpositions. -
DrawCommand— the paint-pass instruction set; the one thing widgets actually emit. -
Picture/PictureRecorder— the recorded, replayable command list. -
SkiaCanvas— CPU rasterizer (tiny-skia) and GPU-shapes command partitioner, depending on mode. -
FontCache— fontdue-backed glyph rasterization + cache; shared by CPU blit and GPU atlas paths vialayout_glyphs. -
GpuPresenter— wgpu surface owner; uploads/caches textures and composites layers. -
CompositorLayer— the primitive-typed interface between the frame loop and the compositor.
-
Three-pass Flexure layout (measure/place/paint), not a single recursive pass. D013/D014 split layout from paint explicitly so a widget's size can be resolved before anything is drawn — required for alignment,
Fractionsizing relative to a parent, and intrinsic sizing (D016, opt-in only, zero cost when unused). -
tiny-skia, notskia-safe. D032 locked "Skia" as the renderer, but the actual dependency istiny-skia— a pure-Rust CPU-only reimplementation of Skia's software backend, not the fullskia-safeC++ binding. This is a real deviation from the letter of D032, not a documentation gap; it's what made D109 (below) necessary once animation made CPU rasterization the bottleneck. -
Moving off
tiny-skiaonto GPU shaders is a scoped, in-progress migration, not a rewrite. D109 (.steering/DECISIONS.md, Phase 27, status scoped, not fully complete) is the decision behind GPU-shapes mode: every built-in shape becomes a registeredwgpu::RenderPipeline(SDF fragment shader, no CPU tessellation), text moves to a cached GPU glyph atlas built on the existingFontCache(explicitly not by adoptingcosmic-text/glyphonas dependencies — glyphon is cited as prior art for the mechanism only). The reasoning cites Flutter's Impeller, Compose's Skia-GPU backend, and SwiftUI's Metal compositing as the field's converged answer, and ties it directly to D108's pervasive default animation: an animated widget is dirty every frame by construction, so damage-rect/frame-skip (which only help idle apps) can't hide the CPU rasterization cost.tiny-skiais deliberately not deleted in this phase — it stays as the desktop softbuffer fallback and the entire web/wasm path (which never touches the GPU at all, per D109's own caveat). -
The compositor is a strict Layer 0 crate with zero
rosace-*dependencies. This is what forcesShaderFill'spipeline_id/uniformsto be rawu64/Vec<u8>instead of typedPipelineId/ShaderUniforms—rosace-compositorcannot importrosace-shader's types even though it's the thing that ultimately compiles and runs the pipelines.rosace-platform(already the compositor's only consumer) does the typed-to-primitive conversion at the boundary. See D109 and the layer-cake rule in README.md. -
The sRGB gamma bug is why texture format matters more than D073/D075 originally assumed. D073/D075 locked
Rgba8Unormwith "no sRGB correction (tiny-skia already handles gamma)" — buttiny-skia's output bytes are gamma-encoded sRGB, and uploading them as plainUnorm(not*Srgb) meant the GPU never linearized them before the sRGB-format swapchain re-encoded on write, double-applying the gamma curve. Fixed 2026-07-08 by switching the layer texture format toRgba8UnormSrgb(see the fix's own comment inrosace-compositor/src/lib.rs), verified by sampling actual rendered pixels (#2B2D30was rendering as(96,98,102), the exact shape of a doubled sRGB curve). Documented in project_render_gamma_fix territory — a concrete case where "the code disagrees with the decision doc" and the code (once fixed) wins.
-
DrawCommands are recorded in logical pixels; onlyplay_picturescales to physical. If you're adding a newDrawCommandvariant, it must be threaded through bothoffset()(Hero/damage translation) andmorph()(Hero transitions) — every existing variant is, by design (see D109's note onShaderFill's API surface). -
ShaderFillhas no CPU rasterization path, on purpose.SkiaCanvas::play_picturenever draws it — it only collects it intopending_shader_quads/CanvasFrameItem::Shader, physical-px, with the widget clip stack captured at record time. Forgetting to draintake_shader_quads()/take_frame_items()each frame silently leaks GPU quads across frames. -
GPU-shapes mode is always a full repaint. Damage-rect clearing is a CPU-pixel-buffer economy; once the frame is re-expressed as an ordered item list, there's no "dirty rectangle" of a texture to partially replay. Don't expect damage-rect logs/behavior when
canvas.gpu_shapes()is true. -
A
Picturereplayed on a different canvas scale is still correct — aPicturereplayed with a different logical size is not. Coordinates are logical;play_pictureonly ever multiplies byscale. If you cache aPictureand reuse it after a layout change, you must re-record, not just re-scale. -
RefreshEngine-style fine-grained rebuild determines what gets rebuilt before layout ever runs — layout itself has no dirty-tracking of its own. A component that's marked clean skipsbuild()entirely (see state-and-reactivity.md) and its cachedElementstill goes through the full layout+paint walk that frame unless the containing subtree is also skipped by frame-skip. Layout is not memoized per-node the way build is. -
Rgba8UnormSrgb, notRgba8Unorm, is required for correct color on the compositor's cached layer textures. If you ever add a new texture upload path inrosace-compositor, match the existing.find(|f| f.is_srgb())surface-format query and the*Srgbtexture format — reintroducing plainUnormreproduces the exact double-gamma bug described above. -
Web/wasm never constructs a
GpuPresenter.rosace-platform's web target presents via 2D-canvasputImageData, so GPU-shapes mode and the wgpu compositor path are desktop/mobile-only until a WebGPU presenter is scoped as its own phase (named in D109). Don't assume GPU-shapes behavior when reasoning about the web target.