Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

Chroix — Display List Contract

Источник: dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md (read-only; сгенерировано scripts/sync-docs.mjs)

Status: draft / pre-implementation. This is the first concrete contract in the framework: it fixes the backend API, the testing story, and how real “replaceability” is. Written against UI-FRAMEWORK-IDEAS-V2.md §5 and the research synthesis in §20.

Scope: this document specifies the data format of the display list, its versioning, its damage representation, and the backend trait that consumes it. It does not specify how the list is built (that’s the view/pipeline layer) or how damage is computed (that’s the diffing layer) — only what the list is when it crosses the boundary.

From V2 and Fera ADRs, non-negotiable:

  • Plain data, no engine types. If a Vello/Parley/wgpu type ever appears in a public signature here, the contract is broken. (ADR-009, ui-contract rule.)
  • Crosses the fload boundary as a buffer, not as structs. Generated encode/decode, shared by both sides, versioned schema. (V2 §20.2.)
  • Damage regions are first-class. The backend receives “what changed”, not “draw everything”. (V2 §5, §20.6.)
  • Serializable + diffable + replayable. Golden-frame tests and frame record/replay are side benefits we get for free if the format is honest data. (V2 §5.)
  • Zero per-frame allocation on the hot path. Transport is an arena / ring buffer; the list is rewritten in place frame-to-frame. (V2 §5, §19.)
  • One contract, two backends. Vello and headless consume the same bytes. If a feature can’t be expressed in this format, it doesn’t exist. (V2 §16.)
Model (Elm state)
└─ view!(state) ──► Element tree (typed, compiled, static/dyn split)
└─ layout ──► Layout tree (Vec<Node>, slotmap ids, per-node cache)
└─ build ──► Display list (this document)
└─ backend ──► pixels

The display list is the only thing a backend ever sees. It never sees the element tree, the layout tree, the model, or the signal graph.

A display list is an append-only flat buffer with a small header. It is built once per frame (or per damage-tick), consumed once, then either reused (frame N+1 rewrites the same arena) or diffed against the previous frame.

[ Header ][ Op stream ][ Side tables ]
  • Header — version envelope, frame id, damage summary, side-table offsets.
  • Op stream — a sequence of variable-length ops in paint order (back to front). Ops reference side tables by index, never embed large data inline.
  • Side tables — string/blob pool, glyph-run pool, image-ref pool, transform stack pool. Dense, index-addressed, shared across ops.

Everything is offset-based (u32 offsets from buffer base), never raw pointers, so the buffer is position-independent and can live in shared memory between host and backend. (V2 §20.3, research “Offset-based pointers”.)

Every buffer starts with a fixed-size header, append-only, versioned by the Vulkan sType/pNext pattern adapted to a flat buffer:

struct Header {
magic: u32, // 'CHDL' (0x43_48_44_4C)
header_size: u32, // size of this header in bytes, for forward compat
abi_version: u16, // major.minor of the display-list schema
_pad: u16,
frame_id: u64, // monotonically increasing, from the scheduler
op_count: u32,
op_stream: u32, // offset to op stream
strings: u32, // offset to string pool
glyph_runs: u32, // offset to glyph-run pool
images: u32, // offset to image-ref pool
transforms: u32, // offset to transform pool
damage: u32, // offset to damage region list
damage_count:u32,
}

Rules:

  • Append-only. New fields go at the end; old readers stop at their known header_size. Removing a field is a breaking schema bump.
  • abi_version is bumped on any layout change. A backend whose schema fingerprint doesn’t match rejects the buffer explicitly, never UB.
  • frame_id ties the buffer to the scheduler’s frame counter; backends use it for caching (“I already have frame N, is this N+1?”).

The op stream is a sequence of ops, each prefixed by a 1-byte opcode and a 3-byte little-endian length (so ops are skippable without understanding them). Paint order is stream order; there is no z-index — if you need z, you emit in the right order.

Op vocabulary, grouped:

State ops (affect subsequent ops until popped):

  • PushClip { rect } / PopClip — clip stack.
  • PushTransform { transform_ref } / PopTransform — affine stack.
  • PushLayer { alpha, blend, bounds } / PopLayer — compositing layer (maps to Vello push_layer).

Draw ops:

  • FillRect { rect, brush_ref } — solid/gradient brush.
  • StrokeRect { rect, brush_ref, width }.
  • FillPath { path_ref, brush_ref } — path is an opaque blob in the blob pool (kurbo-encoded), so the schema doesn’t grow a path grammar.
  • DrawGlyphs { glyph_run_ref, origin, brush_ref } — references a glyph run in the side table (see §6).
  • DrawImage { image_ref, src_rect, dst_rect, filter } — references an image entry (see §7).

Meta ops (no pixels, used by hit-testing/semantics/diffing):

  • NodeBoundary { node_id, kind } — marks where a layout node’s ops start/end; carries the slotmap NodeId so damage and hit-testing can map back.
  • HitRegion { node_id, rect, flags } — explicit hit-test region (single-pass hit-testing, ADR-030).

That’s it. The vocabulary is deliberately small: if a backend feature can’t be expressed as one of these, the format grows a new op (schema bump), not the backend growing a private channel.

  • Brush = tagged union in a side table: Solid(Rgba) | LinearGradient { stops: &[ColorStop], start, end } | RadialGradient { ... }. Stops live in the blob pool; brushes are indexed.
  • Transform = [f32; 6] affine (a,b,c,d,e,f), stored in the transform pool, referenced by index so PushTransform stays small.
  • Blob pool — one big byte region; paths, gradient stop arrays, and any future large inline data live here as (offset, len).

Text is shipped shaped but not rasterized, as glyph runs in the side table. (V2 §20.6 “Display list as glyph-run + image-ref”.) Verified against Parley 0.11.1 source.

struct GlyphRun {
font_key: u64, // (font_blob_id, font_index, normalized_coords_hash,
// synthesis, size_bucket, subpixel_bucket) — interned
origin_x: f32, // run origin, absolute (baseline-resolved)
origin_y: f32,
brush_ref: u32, // into brush table (color/gradient for this run)
glyph_count: u32,
glyphs: u32, // offset into glyph pool
}
struct Glyph {
id: u32, // font-local glyph id (HarfBuzz output; treat as u32)
x: f32, // absolute within run origin (from positioned_glyphs)
y: f32,
}

Key facts from the Parley source (0.11.1):

  • Granularity matches. Parley’s GlyphRun is exactly display-list granularity: already split by font (fallback), by BiDi level, and by style (brush/color). GlyphRun::positioned_glyphs() yields glyphs in visual order with absolute positions — RTL needs no special handling at draw time. One GlyphRun in the contract = one GlyphRun in Parley.
  • Glyph.id is font-local — meaningless without the font. The atlas key is (font_blob_id, font_index, normalized_coords, synthesis, size_bucket, subpixel_bucket). font_blob_id comes from FontData::id() (content- addressed, stable across frames for the same font bytes).
  • Layout is ownable. Layout<B> is fully owned, no borrow of the input text or the contexts — it can be cached across frames. But it does not retain the source string, so selection/copy/accessibility must keep the string alongside (only byte ranges survive).
  • No incremental atlas help. Parley has no dirty flags or “what’s new” — the backend diffs at the glyph level each frame: walk runs, build the set of atlas keys, rasterize whatever’s missing. This is the standard approach (it’s what Vello’s own glyph cache does).
  • Cache key for a shaped layout = hash(text, style_spans, scale, quantize, width, font_set_generation) — and font_set_generation is a counter we maintain (Parley has none), bumped whenever fonts are registered/unregistered.
  • Who shapes: the pipeline layer, using Parley, before the display list is built. The display list never shapes. Shaping is width-independent; line-breaking is width-dependent and cheap on top (V2 §14).
  • Subpixel positioning: quantized to a small fixed bucket count (e.g. 4×4) in the font_key; exact offset applied at draw time. The contract states its subpixel policy explicitly so backends don’t guess.
struct ImageRef {
content_hash: u64, // content-addressed key (ADR-017)
width: u32,
height: u32,
format: u8, // rgba8, bgra8, ...
_pad: [u8;3],
source: u32, // offset into blob pool, or 0 = "backend cache hit"
}
  • Content-addressed: the key is a hash of the decoded pixels, not a file id. If the backend already has content_hash in its texture cache, the buffer carries source = 0 and no pixels — a pure reference.
  • Atlas-aware: the backend is free to pack images into its atlas; the contract doesn’t care. (V2 §20.6 texture atlas.)
  • Animated/sticker frames are just a sequence of ImageRefs with the frame index chosen by the pipeline, not the backend — the backend is stateless about animation.

Damage is a separate side table, a flat list of rects:

struct DamageList { count: u32, rects: [Rect] } // appended at damage offset
  • What it means: the union of these rects is the only region a backend must re-render this frame. Everything else may be re-presented from cache.
  • How it’s computed: by the diffing layer (pipeline), not the backend. The backend consumes damage; it never computes it. (V2 §20.6 — damage flows from the compiled signal graph for free.)
  • Coarse, not fine: the diffing layer merges nearby rects above a threshold (one big rect beats a thousand small ones). The contract carries the merged list.
  • Damage is a hint, not a correctness requirement. A backend may ignore damage and re-render everything (correct, just slower). This is what keeps the headless backend trivial, and it’s the realistic shape on Vello 0.10 anyway (see below).
  • Full-frame damage is represented as a single rect covering the viewport, not an empty list — empty means “nothing changed, re-present frame N-1”.

8.1 What the Vello backend can actually do with damage

Section titled “8.1 What the Vello backend can actually do with damage”

Verified against Vello 0.10.0 / wgpu 30 sources:

  • No sub-scene patching. Vello’s only composition primitive is Scene::append(&other, transform), which is O(N) flat copy. There are no stable node IDs, no replace-range, no scene graph. The contract must NOT promise sub-scene patching at the Vello boundary.
  • Scene::reset() + full rebuild is the documented pattern. Reset keeps capacity (6 × Vec::clear), encoding is append-only — rebuilding a few thousand items is microseconds. This is the baseline, and it’s cheap enough to be the default.
  • wgpu 30 exposes no partial present. No damage-region API on Queue::present. The escape hatches are platform-specific: DXGI Present1(DXGI_PRESENT_PARAMETERS) via Surface::as_hal::<dx12::Api>(), or Vulkan VK_KHR_incremental_present via raw_native_swapchain() — both require unsafe raw-swapchain code and risk racing wgpu’s own present.
  • The realistic damage-aware path is a persistent intermediate texture: keep a frame-sized texture alive, render only the damaged sub-scene into it (scissored), then blit damaged regions to the surface. This costs one extra texture (~33 MB @ 4K) and one blit per frame, and requires writing your own region rasterizer because Renderer::render_to_texture doesn’t take a scissor. This is an opt-in backend mode, not the baseline.
  • What Vello retains for free: an internal glyph-outline cache (keyed by font+glyph+size+coords, invisible to us) and a manual image atlas (register_texture/mark_image_dirty). Everything else re-rasterizes every frame — that’s fine, because encoding is cheap and GPU raster of a full scene is the fast path anyway.

So the contract’s damage list is honest: the baseline backend ignores it, the optimized backend uses it for scissored composite or platform dirty-rect hints. The contract doesn’t change either way — damage is data, and the backend decides what it’s worth.

The Rust-side trait a backend implements. This is the consumer of the buffer, living on the far side of the fload boundary:

pub trait Backend {
type Error;
/// Called once per frame (or per damage tick).
/// `buf` is the display-list buffer, valid for the duration of the call.
/// The backend may retain a copy of the *header* and damage summary,
/// but must not retain `buf` itself.
fn present(&mut self, buf: &[u8], ctx: &mut PresentCtx) -> Result<(), Self::Error>;
/// Viewport resize. Backends reallocate surfaces/layers here.
fn resize(&mut self, size: Size) -> Result<(), Self::Error>;
/// Schema fingerprint this backend was built against. The host checks
/// it against the buffer's abi_version before calling present.
fn schema_fingerprint(&self) -> u64;
}

Notes:

  • PresentCtx carries host-leased scratch (the bump arena the backend may use), the glyph-atlas handle, the image cache handle, and timing info.
  • The trait is deliberately tiny. Everything else (damage, caching, atlases, partial present) is backend-internal — the contract doesn’t prescribe how a backend achieves its frame budget, only what it receives.
  • The headless backend is Backend over a CPU buffer, writing PNGs for golden-frame tests. Same bytes in, pixels out.

10. What this contract deliberately does NOT say

Section titled “10. What this contract deliberately does NOT say”
  • How the view layer builds the list (that’s view! / the pipeline).
  • How damage is computed (that’s the diffing layer).
  • How a backend schedules GPU work, atlases textures, or does partial present (that’s backend-internal, and Vello-vs-headless will differ wildly).
  • Anything about input, focus, IME, or semantics — those are separate contracts (reverse channel, V2 §13).
  • Subpixel glyph quantization: 6×6 buckets, configurable at compile time. 6×6 is the default (good balance of atlas hit rate vs quality on 1×-2× displays); 8×8 or 4×4 can be chosen per build for high-DPI or low-end targets.
  • NodeBoundary: parallel index, not in the op stream. The op stream stays tight (draw ops only); a separate node_index side table maps op ranges to NodeIds for hit-testing and diffing. Backends that don’t need it skip the table entirely.
  • Blend modes: minimal set — Normal, Multiply, Screen, Overlay. What Fera needs, no more. Full SVG set is a future schema bump if a consumer asks for it.
  • Damage merge threshold — when to merge many small rects into one big one. Needs the benchmark scene to measure; start with “merge if count > 8 or total area > 40% viewport”.

Drafted 2026-10-03 against V2 §5/§19/§20. Feeds OQ-5.