Chroix — Display List Contract
Источник:
dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md(read-only; сгенерировано scripts/sync-docs.mjs)
Chroix — Display List Contract
Section titled “Chroix — Display List Contract”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.
0. Design constraints inherited
Section titled “0. Design constraints inherited”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.)
1. The pipeline this sits in
Section titled “1. The pipeline this sits in”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 ──► pixelsThe 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.
2. Top-level shape
Section titled “2. Top-level shape”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”.)
3. The versioned envelope
Section titled “3. The versioned envelope”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_versionis bumped on any layout change. A backend whose schema fingerprint doesn’t match rejects the buffer explicitly, never UB.frame_idties the buffer to the scheduler’s frame counter; backends use it for caching (“I already have frame N, is this N+1?”).
4. Ops
Section titled “4. Ops”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 Vellopush_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 slotmapNodeIdso 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.
5. Brushes, transforms, blobs
Section titled “5. Brushes, transforms, blobs”- 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).
6. Text: glyph runs, not pixels
Section titled “6. Text: glyph runs, not pixels”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
GlyphRunis 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. OneGlyphRunin the contract = oneGlyphRunin Parley. Glyph.idis font-local — meaningless without the font. The atlas key is(font_blob_id, font_index, normalized_coords, synthesis, size_bucket, subpixel_bucket).font_blob_idcomes fromFontData::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)— andfont_set_generationis 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.
7. Images
Section titled “7. Images”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_hashin its texture cache, the buffer carriessource = 0and 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.
8. Damage regions
Section titled “8. Damage regions”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: DXGIPresent1(DXGI_PRESENT_PARAMETERS)viaSurface::as_hal::<dx12::Api>(), or VulkanVK_KHR_incremental_presentviaraw_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_texturedoesn’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.
9. The backend trait
Section titled “9. The backend trait”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:
PresentCtxcarries 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
Backendover 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).
11. Resolved questions
Section titled “11. Resolved questions”- 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_indexside table maps op ranges toNodeIds 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.
12. Still open
Section titled “12. Still open”- 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.