Rendering Pipelines & Automatic Verification

July 2, 2026 · View on GitHub

CrymbleUI has two rendering pipelines that produce the same visual output from the same widget code. The Crymble pipeline (default) uses multi-level caching for performance. The Immediate-mode pipeline bypasses all caches to produce a ground-truth reference. The cache validation framework automatically compares them pixel-by-pixel to catch caching bugs.

What Widget Authors Need to Know

Almost nothing about the rendering pipeline. The entire caching and verification system is invisible to widget code. A widget only needs to:

1. Define Primitives

Override to_primitives() to describe what to draw using backend-agnostic DrawPrimitive structs. Coordinates are widget-local (0,0 origin):

class MyButton < Widget
  def to_primitives(bounds : Rect) : Array(DrawPrimitive)
    primitives do
      fill_rect(bounds, @background_color)
      draw_rect(bounds, @border_color, 1.0)
      draw_text(@label, Vec2.new(8.0, 4.0), @text_color, 14)
    end
  end
end

The renderer handles everything else: creating GPU textures, caching primitives, memorizing backgrounds, compositing layers, and validating correctness.

2. Use Property Macros for Invalidation

Property macros declare a widget's reactive fields. Reading the getter in to_primitives auto-captures it as a dependency, so a change re-renders the widget — no manual invalidation call:

class MyButton < Widget
  reactive_property color : Color = Color::White            # any reactive value — the default
  reactive_property padding : Float64 = 0.0, layout: true   # also pokes layout (size/position change)
  reactive_property hover : Bool = false, reconcile: true   # value survives a DSL rebuild
  reconcile_property layer : Layer? = nil                   # plain ivar carried across rebuilds (never painted)
  theme_property text_color, button_text                    # getter resolves Theme.current.button_text live
end
  • reactive_property → a Source(T) field; the getter auto-captures (read it in to_primitives and a change re-renders), the setter notifies. The default for anything painted. Flags: layout: true (setter also calls mark_needs_layout), reconcile: true (value survives a DSL rebuild).
  • reconcile_property → a plain ivar (no Source) carried across a rebuild — for infrastructure refs (a Layer/Widget) that are never painted.
  • theme_property → a getter that resolves a global Theme Source, so the color follows the theme.

render_property and layout_property no longer exist. See REACTIVITY.md for the canonical model (auto-capture, version counting, the pull render trigger, and which macro to reach for) — that doc is the single source of truth for the macros.

One trap worth knowing up front: inside a viewport_cache layer (VirtualMatrix, ScrollView content) a transient mark_needs_render is silently dropped — those layers ignore dirty_widgets and slot-skip by version, so an appearance change there must move a version (a reactive_property read, or mark_needs_layout). The full "what invalidates what" reference is the Cache-Coherency Contract in LAYER_RENDERING_ARCHITECTURE.md.

3. Choose a Cache Policy (Optional)

Override cache_policy if the default (Dynamic) isn't right:

def cache_policy : CachePolicy
  CachePolicy::Static   # Never changes after first render (icons, labels)
  CachePolicy::Dynamic  # Default — re-render when state changes
  CachePolicy::Never    # Always generate fresh primitives (menus, popups)
end

What You Do NOT Touch

The renderer manages all of this internally — widget code never interacts with:

  • widget_backend / background_backend (per-widget GPU textures)
  • Background memorization and restoration
  • Layer compositing and viewport caching
  • Blit-shift optimization during scroll
  • The fast-path cache check
  • Coordinate translation (widget-local → layer-local → buffer-relative)

The Crymble Pipeline (Cached, Default)

Every frame proceeds through three passes:

Layout pass (O(dirty path), skipped if unchanged)

Widgets with needs_layout? recompute bounds top-down. Template method pattern: layout() checks constraints → perform_layout() runs if changed → recurses to children. Unchanged subtrees are skipped entirely.

Render pass (O(dirty widgets) selective, O(visible) full)

Each widget owns two small GPU textures:

  • widget_backend: the widget's rendered content (background + primitives)
  • background_backend: memorized parent content at the widget's position

Rendering a widget:

  1. Restore background: blit background_backendwidget_backend
  2. Render primitives: execute DrawPrimitive list on widget_backend
  3. Blit to layer: copy widget_backend → layer buffer at widget position

Fast path (~90% of widgets during scroll): the live cache gate is slot_fresh?(buffer_pos), then has_valid_primitive_cache? && slot_rev_matches?. slot_fresh? short-circuits first — when the cell's pixels already sit at this buffer position with a matching revision (slot_rev == primitives_version), there is nothing to do. Otherwise, a valid primitive cache plus a matching slot revision re-blits the cached widget_backend, skipping steps 1-2. Both are version-keyed (auto-captured primitives_version), so no widget re-renders unless a captured source actually moved. This is O(1) per widget — no primitive generation, no background restoration.

Selective rendering: only widgets in dirty_widgets are re-rendered. Clean widgets keep their cached content in the layer buffer from previous frames.

Composite pass (O(layers))

Layer textures are blitted to the window sorted by z-index. For viewport_cache layers (scrollable content), the compositor samples the correct viewport region from the larger buffer using texture_rect. This makes panel drag O(1) — just move the layer position, no re-rendering.

Blit-Shift Optimization (Scroll Recenter)

When scrolling moves the viewport beyond the buffer's cache extent:

  1. Compute overlap between old and new buffer regions
  2. Copy overlap to temp → clear buffer → restore at new position
  3. Classify each widget against the copied overlap region:
    • Fully inside the overlap: pixels were faithfully moved — call shift_slot(dx, dy) (backend kept, slot stamp updated, no re-render)
    • Inside the old buffer but outside the overlap: backend is valid but was not moved — call invalidate_slot (backend kept, slot marked stale for a cheap re-blit)
    • Partially clipped at the old buffer edge: backend holds truncated pixels — dispose widget_backend + background_backend (full re-render on next pass)
  4. Render only newly-visible edge cells

Cost: O(edge_cells) instead of O(all_visible). For a 1400×900 matrix with 23px rows, a vertical recenter re-renders ~12 cells instead of ~400.

The Immediate-Mode Pipeline (Uncached Ground Truth)

The immediate-mode pipeline produces pixel-identical output using a naive painter's algorithm that bypasses all caching:

  1. Create a fresh viewport-sized backend, clear to layer.background_color
  2. Collect ALL widgets in parent-first order (depth-first traversal)
  3. For each widget: a. Copy current viewport surface at widget position → serves as background (parent content already painted by painter's algorithm) b. Call to_primitives() directly — NOT get_primitives(), which would return cached primitives for Static/Dynamic policies c. Render primitives on a temp backend sized with device_pixel_span(start, length) — the same formula production uses in render_single_widget. Using ceil(length) instead would add a fractional-edge column absent from the cached buffer and generate false "stale cache" CV mismatches (the instrument, not the cache, would be the bug). d. Blit temp backend to viewport surface
  4. Second pass: render foreground primitives for DecoratedContainers

Key insight: to_primitives() vs get_primitives(). The cached pipeline calls get_primitives() which returns @cached_primitives when the cache policy allows it. The immediate-mode pipeline calls to_primitives() which always generates primitives from scratch. This means cache validation can detect bugs in primitive caching, not just rendering caching.

Compile Flags

FlagEffect
(none)Crymble pipeline only, zero validation overhead
-Dcache_validationBoth pipelines run, pixel comparison after each frame
-Dimmediate_mode_onlyOnly immediate-mode pipeline, all caching bypassed
-Dverify_boundsAsserts the viewport-cache buffer_origin invariant at its sole writer and at both composite seams (SFMLRenderer + TestRenderer); raises on a non-whole origin or a composite that would clamp. Paired with -Dcache_validation by tools/cv-coherency.sh as the buffer-origin gate.

-Dimmediate_mode_only is useful for visual debugging: if a rendering bug disappears with this flag, the bug is in caching. If it persists, the bug is in layout or primitive generation.

Cache Validation Framework

How It Works

With -Dcache_validation, after each frame's Crymble render of a viewport_cache layer, the validator:

  1. Captures the viewport region from the cached buffer (the "cached result")
  2. Renders the same layer via immediate-mode painter's algorithm (the "fresh result")
  3. Compares pixel-by-pixel

Any mismatch means a caching bug: the optimized path diverges from ground truth.

Validation Levels

LevelCacheStatusWhat It Catches
1Immediate mode (all caches)ImplementedAny divergence between cached and uncached rendering
2Blit-shift (overlap copy)ImplementedPixel corruption during buffer recenter
3Dirty widget trackingPlannedSelective render missing a dirty widget
4Widget fast-pathPlannedFast-path blit using stale widget_backend
5Primitive cachePlanned@cached_primitives diverging from to_primitives()
6Blit-plan fast-pathCovered via ImmediateMode validator (not a standalone check)Sticky layer shortcut producing wrong output
7Layout cachePlannedSkipped layout producing wrong bounds

Runtime Control

CrymbleUI::CacheValidation.enable(:immediate_mode)
CrymbleUI::CacheValidation.enable(:blit_shift)
CrymbleUI::CacheValidation.disable_all

# After running:
CrymbleUI::CacheValidation.assert_no_failures!
# Or inspect:
CrymbleUI::CacheValidation.failures.each { |f| puts f }

Headless vs SFML

BackendWhat It CatchesLimitation
TestRenderBackend (headless)Logic bugs in caching, coordinate math, widget collectionNo GPU — can't detect SFML-specific FBO/texture quirks
CrSFMLBackend (SFML)All of the above + GPU-specific issues (Y-flip, texture staleness, scissor state)Requires display (DISPLAY=:0 or Xvfb)

Both backends implement the same RenderBackend interface, and both support capture_region_pixels for pixel comparison. The immediate-mode pipeline works identically on both.

Some bugs are SFML-only (e.g., the blit-shift boundary truncation bug where widget_backends retained stale FBO clipping state). These can only be caught by SFML autotests with -Dcache_validation, not by headless specs.

Writing Cache Validation Tests

# Headless spec (crystal spec -Dcache_validation)
{% if flag?(:cache_validation) %}
describe "Cache Validation" do
  before_each do
    CrymbleUI::CacheValidation.clear_failures!
    CrymbleUI::CacheValidation.enable_all
  end
  after_each { CrymbleUI::CacheValidation.disable_all }

  it "cached rendering matches immediate mode after scroll" do
    renderer = CrymbleUI::Testing::TestRenderer.new(800, 600)
    app = MyTestApp.new
    app.build_tree
    renderer.settle_rendering(app)

    # Scroll to trigger blit-shift
    scroll_widget(app, 7.times)
    renderer.settle_rendering(app)

    CrymbleUI::CacheValidation.assert_no_failures!
  end
end
{% end %}

The Cache-Coherency Gate

tools/cv-coherency.sh is the dual-renderer gate. It builds with both -Dcache_validation and -Dverify_bounds in a single compile, then runs over 20 spec files covering VirtualMatrix, ScrollView, and non-matrix overlay specs. Any cached-buffer divergence from the immediate-mode ground truth, or any buffer-origin invariant violation, raises immediately.

Usage: source setup.sh && ./tools/cv-coherency.sh

Non-matrix layers (overlays, combo popups, window-direct) opt in per-layer with layer.cv_validate = true. The validator condition is (viewport_cache && !sticky) || cv_validate, so a non-scrolling layer with this flag set exercises the full pixel comparison without the scroll-offset complication. Sticky layers are excluded — the immediate-mode path mis-positions sticky cells and would produce false positives.

Performance Model

OperationCostExample
Leaf widget change (hover, text edit)O(1) ~0.2msButton hover in 400-button panel
Parent widget change (resize, recolor)O(children) ~154msRare, correct cascade
Panel dragO(1) <0.1msCompositor only, no re-render
Scroll (within cache)O(0)Viewport shifts, no rendering
Scroll (blit-shift recenter)O(edge_cells) ~2ms~12 cells re-rendered
Scroll (full recenter)O(visible) ~50msNo overlap, all cells re-rendered
Full layoutO(n) ~76msWindow resize