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→ aSource(T)field; the getter auto-captures (read it into_primitivesand a change re-renders), the setter notifies. The default for anything painted. Flags:layout: true(setter also callsmark_needs_layout),reconcile: true(value survives a DSL rebuild).reconcile_property→ a plain ivar (noSource) carried across a rebuild — for infrastructure refs (aLayer/Widget) that are never painted.theme_property→ a getter that resolves a globalThemeSource, 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_cachelayer (VirtualMatrix, ScrollView content) a transientmark_needs_renderis silently dropped — those layers ignoredirty_widgetsand slot-skip by version, so an appearance change there must move a version (areactive_propertyread, ormark_needs_layout). The full "what invalidates what" reference is the Cache-Coherency Contract inLAYER_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:
- Restore background: blit
background_backend→widget_backend - Render primitives: execute
DrawPrimitivelist onwidget_backend - 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:
- Compute overlap between old and new buffer regions
- Copy overlap to temp → clear buffer → restore at new position
- 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)
- Fully inside the overlap: pixels were faithfully moved — call
- 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:
- Create a fresh viewport-sized backend, clear to
layer.background_color - Collect ALL widgets in parent-first order (depth-first traversal)
- 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 — NOTget_primitives(), which would return cached primitives for Static/Dynamic policies c. Render primitives on a temp backend sized withdevice_pixel_span(start, length)— the same formula production uses inrender_single_widget. Usingceil(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 - 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
| Flag | Effect |
|---|---|
| (none) | Crymble pipeline only, zero validation overhead |
-Dcache_validation | Both pipelines run, pixel comparison after each frame |
-Dimmediate_mode_only | Only immediate-mode pipeline, all caching bypassed |
-Dverify_bounds | Asserts 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:
- Captures the viewport region from the cached buffer (the "cached result")
- Renders the same layer via immediate-mode painter's algorithm (the "fresh result")
- Compares pixel-by-pixel
Any mismatch means a caching bug: the optimized path diverges from ground truth.
Validation Levels
| Level | Cache | Status | What It Catches |
|---|---|---|---|
| 1 | Immediate mode (all caches) | Implemented | Any divergence between cached and uncached rendering |
| 2 | Blit-shift (overlap copy) | Implemented | Pixel corruption during buffer recenter |
| 3 | Dirty widget tracking | Planned | Selective render missing a dirty widget |
| 4 | Widget fast-path | Planned | Fast-path blit using stale widget_backend |
| 5 | Primitive cache | Planned | @cached_primitives diverging from to_primitives() |
| 6 | Blit-plan fast-path | Covered via ImmediateMode validator (not a standalone check) | Sticky layer shortcut producing wrong output |
| 7 | Layout cache | Planned | Skipped 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
| Backend | What It Catches | Limitation |
|---|---|---|
| TestRenderBackend (headless) | Logic bugs in caching, coordinate math, widget collection | No 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
| Operation | Cost | Example |
|---|---|---|
| Leaf widget change (hover, text edit) | O(1) ~0.2ms | Button hover in 400-button panel |
| Parent widget change (resize, recolor) | O(children) ~154ms | Rare, correct cascade |
| Panel drag | O(1) <0.1ms | Compositor 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) ~50ms | No overlap, all cells re-rendered |
| Full layout | O(n) ~76ms | Window resize |