Donner 0.5.1
Embeddable browser-grade SVG2 engine
Loading...
Searching...
No Matches
Design: Composited Rendering

Status: Draft Author: Claude Opus 4.6 Created: 2026-04-13

Summary

When a user drags a shape in Donner's editor, today's pipeline re-rasterizes the entire document every frame — O(N) work regardless of what changed. This design introduces a compositor that caches rasterized content in off-screen backing stores (layers), so frame-to-frame cost during interactive manipulation is proportional to the number of changed layers, not total scene complexity.

The compositor sits between the ECS computed tree and the RendererInterface backends. It is a shared, backend-agnostic component: one implementation drives TinySkia, Skia, and Geode through a narrow primitive set that each backend already (mostly) exposes. The critical invariant is pixel-identical correctness — composited output must match a full re-render within a precisely defined tolerance, verified continuously in tests.

The engine resolves layer assignment from a cascade of weighted hints — mandatory SVG constraints, active animations, user interaction, and complexity-based pre-chunking. The editor publishes interaction hints but does not drive layer lifecycle. Promotion is a derived value, not an imperative API: sources publish weighted CompositorHintComponent tuples, a LayerResolver system collapses them into a ComputedLayerAssignmentComponent each frame, subject to a hard layer budget. This mirrors the style cascade (StyleComponentComputedStyleComponent) — a well-understood pattern in this codebase.

This design builds on 0005-incremental_invalidation (dirty-flag propagation from DOM mutations through the ECS pipeline) and 0020-editor (editor interaction model, mutation seam, drag/select tools). It does not duplicate their work — it consumes DirtyFlagsComponent as input and produces layer-level dirty notifications as output.

Goals

  1. 60 fps dragging a single promoted shape in a 10,000-node scene on an Apple M1 (16.67 ms frame budget). Measured as time from composition transform update through composited frame completion, excluding display sync. Expected per-frame cost: ~5 ms on TinySkia (CPU blit
    • premultiply-aware path), ~3 ms on Skia (saveLayer + drawImage), <1 ms on Geode (GPU texture blit). Baseline today: >200 ms for 10k nodes (full re-render).
  2. Pixel-identical composited output. For every test in renderer_tests and resvg_test_suite, the composited path must produce output that matches the full-render path with threshold=0, maxDiffPixels=0 (excluding documented AA-tolerance cases, which must be enumerated explicitly and capped at ≤2 LSB per channel, ≤0.1% of pixels).
  3. Correctness verification in every CI run. A Bazel test flag (--//donner/svg/compositor:dual_path_assertion=true) enables the dual-path assertion that runs both paths (full render + composited) on a subset of the test suite and fails on any drift. This is always enabled in CI and compositor-specific test targets. It is NOT enabled in debug builds globally (the per-frame cost of two full renders would break interactive debugging). Developers can opt-in locally via --config=compositor-debug.
  4. No backend-specific compositor code. The compositor is a single implementation in donner::svg::compositor that emits calls through RendererInterface. Backend-specific optimizations (Skia's SkPicture cache, Geode's GPU texture retention) live behind the existing RendererInterface virtuals or backend-internal caches, not in compositor logic.
  5. Compositor owns layer lifecycle. Promotion is derived from a weighted-hint cascade, not an imperative API. Any subsystem (mandatory-feature detector, animation system, editor, complexity bucketer) publishes hints via ScopedCompositorHint; a LayerResolver collapses hints into a ComputedLayerAssignmentComponent each frame. Explicit CompositorController::promoteEntity survives as an escape hatch for tests and unusual extensions; it is not the primary mechanism, and production code paths use scoped hints. Promotion is always reversible (dropping the scoped hint demotes automatically).
  6. Click-to-first-drag-update latency. p50 < 16 ms, p99 < 33 ms on a 10k-node scene, measured from selection-change hint (ScopedCompositorHint(E, Interaction)) to the first composited frame with the drag delta applied. The interactive-layer slot is pre-allocated at startup and reused across clicks, so the hot path never allocates. Benchmarked by click_to_first_drag_update_benchmark (added in Phase 2).
  7. Animation isolation. Per-frame cost of one active SMIL/CSS animation is O(animated subtree), not O(document). The animation system (current or future) publishes a high-weight hint on the animated subtree root; the resolver auto-promotes it, so each animation tick re-rasterizes only that subtree's layer. Measured on a 10k-node scene with one animated <circle>: per-frame cost < 2 ms on TinySkia. Forward-compatible: the hint-producer API is stable before the animation system exists.
  8. Pre-chunking cost bound. The document-load-time complexity partition computes in O(N) over the computed tree and consumes < 5% of total parse + ECS-build wall clock on a 10k-node scene. Repartitioning runs only on structural tree rebuild (RenderTreeState::needsFullRebuild), not per-frame.

Non-Goals

  1. Sub-layer partial re-rasterization (dirty rectangles within a single layer). v1 re-rasterizes the entire layer when any element in it changes. Tile-level invalidation is future work.
  2. ML-driven or user-history promotion heuristics. Auto-promotion is first-class in v1 (see Goals 6–8), but promotion weights are hand-tuned deterministic constants. v1 does not learn from user behavior, does not A/B-test weights at runtime, and does not use ML to predict drag targets. The resolver is a pure function of (hints × dirty flags × budget) and must remain fuzz-testable.
  3. Concurrent/parallel layer rasterization. Layers are rasterized sequentially on the calling thread. Thread-pool rasterization is a follow-up optimization. Because of this, hints are published on the main thread from ECS-aware code and the CompositorHintComponent backing store is a plain non-thread-safe small-array.
  4. Promotion from static CSS hints unless animated. Plain transform, will-change, opacity: 1, etc. do not promote by themselves. An animated transform does promote (Goal 7). will-change: transform remains advisory only — parsed, but the resolver assigns it zero weight until v2. This keeps the hint surface small and reduces the risk of layer-budget thrash on documents that declare will-change speculatively.
  5. Perspective transforms. SVG2 does not define perspective. The compositor handles affine transforms only. If a future CSS perspective extension lands, the compositor falls back to full re-render for affected subtrees.
  6. Video or streaming content layers. Layers contain static rasterized content only.
  7. Adaptive / runtime-cost-driven bucketing. Automatic partitioning is now first-class (Goal 8), but v1's complexity bucketer is static: buckets are computed once at load and on structural mutation, not repartitioned per-frame based on measured rasterization cost. If a bucket turns out to be too expensive after load, the user feels it until the next structural rebuild. Adaptive rebucketing is future work.
  8. Hover-driven auto-promotion. v1 does not promote on mouse hover. Hover is a latency-vs-thrash tradeoff (promoting everything the pointer passes over would exceed the layer budget; promoting nothing leaves click-to-drag latency on the table) deferred to v2. InteractionHint::Hover is not exposed in the v1 enum — adding it to the public API shape is a v2 decision, not a v1 placeholder.
  9. Stable bucket identity across edits. The pre-chunk partition is not stable across document edits — structural mutations may reshuffle bucket membership. Callers MUST NOT rely on a given entity staying in the same bucket across frames. Stable-identity buckets (useful for bucket-scoped caching) are out of scope.

Terminology

Term Definition
Layer An off-screen pixel buffer (backing store) containing the rasterized content of one or more elements. Identified by a LayerId.
Composition tree A flat, draw-order-sorted list of layers with associated transforms, opacity, blend mode, clip, and mask metadata. The compositor walks this list to produce the final frame.
Promoted element An element that has been assigned its own layer (backing store). All other elements share the root layer.
Root layer The default layer containing all non-promoted elements. Always exists, always layer 0.
Damage region The axis-aligned bounding box (in device pixels) of all pixels that changed between frames. Used to limit the composition blit area.
Layer promotion The act of assigning an element (and optionally its subtree) its own backing store.
Demotion Returning a promoted element to the root layer, releasing its backing store.
Compositor The component that manages layers, tracks damage, and composes layers into the final frame buffer. Does not rasterize — it delegates rasterization to RendererInterface.
Fast path The composited rendering path: rasterize only dirty layers, compose all.
Ground truth The full-render path: rasterize everything from scratch via RendererDriver.
CompositorHintComponent Author-layer ECS component holding a small-array of {source, reason, weight} tuples published by various subsystems. Any entity may carry one. Inputs to the resolver, analogous to StyleComponent.
ComputedLayerAssignmentComponent Resolved-layer ECS component written by LayerResolver each frame, holding {LayerId, winning reason}. Analogous to ComputedStyleComponent.
ScopedCompositorHint RAII handle. Constructor adds a hint to the entity's CompositorHintComponent; destructor removes it. Drop the handle, the hint disappears, the entity demotes automatically. Primary API for hint producers.
LayerResolver Engine system that each frame walks hint-bearing entities, sums weights per entity, and assigns LayerIds subject to the layer budget. Pure function of (hints × dirty flags × budget); idempotent and fuzz-testable.
Hint source Origin of a hint: Mandatory, Animation, Interaction, ComplexityBucket, Explicit (escape hatch). The compositor does not know which subsystem produced Interaction hints; the editor is just one of several possible producers.
Weight hierarchy Mandatory = infinite (non-negotiable), Animation = high, Interaction = medium, ComplexityBucket = low. Hints compete for the layer budget in this order.
Interactive layer The reserved shared layer slot pre-allocated at startup at a fixed max size. Reused across selections — interaction promotion is O(1) because the backing store already exists.
Complexity bucket A subtree promoted by the complexity bucketer (low-weight hint). Target count ≤ 4 at document load.
Auto-promotion Promotion driven by any hint source other than Explicit. Includes mandatory, animation, interaction, and bucket promotions.
Explicit promotion Escape-hatch promotion via CompositorController::promoteEntity(). Used by tests and extensions, not by production editor code.
Animation-driven promotion High-weight auto-promotion of an animated subtree's root so per-tick cost is O(subtree).
Interaction hint Medium-weight hint published by the editor (or any other consumer) saying "the user is focused on entity E." Source-agnostic: the compositor does not reason about whether "interaction" means selection, drag, or something else.

High-Level Architecture

Where the compositor sits

DOM mutations
DirtyFlagsComponent (from 0005-incremental_invalidation)
createComputedComponents() ── selective recompute (style/layout/shape/paint)
instantiateRenderTree() ── RenderingInstanceComponent, sorted by drawOrder
│ ┌─── Hint producers (publish via ScopedCompositorHint) ───┐
│ │ │
│ │ Mandatory-feature detector (opacity<1, filter, mask) │
│ │ Animation system (high weight on animated subtree) │
│ │ Editor selection/drag (medium, via ScopedCompositorHint)│
│ │ ComplexityBucketer (low, at load / structural rebuild) │
│ │ │
│ └────────┬────────────────────────────────────────────────┘
│ │
│ ▼ writes CompositorHintComponent
┌────────────────────────────┐
│ LayerResolver system │ ◄── NEW
│ • sums per-entity weights │
│ • assigns LayerIds │
│ • enforces layer budget │
│ • writes ComputedLayer- │
│ AssignmentComponent │
└─────────┬──────────────────┘
┌────────────────────────────┐
│ CompositorController │ ◄── NEW: this design
│ (donner/svg/compositor/) │
│ │
│ • Reads ComputedLayer- │
│ AssignmentComponent │
│ • Layer management │
│ • Dirty layer tracking │
│ • Composition pass │
└─────────┬──────────────────┘
│ emits RendererInterface calls
┌──────────────────────────┐
│ RendererInterface │
│ (unchanged API) │
└──────┬──────┬──────┬─────┘
│ │ │
TinySkia Skia Geode

Why a hint cascade, not an imperative API

Donner already resolves styles via a cascade: subsystems write StyleComponent entries; createComputedComponents() collapses them into ComputedStyleComponent. Layer assignment has the same structure — multiple independent subsystems want to influence it, priorities compete, and the final answer is a derived value. Reusing the same pattern buys:

  • Source independence. The editor's selection signal, the animation system's ticker, and the complexity bucketer all publish identically-shaped hints. The compositor does not know the editor exists; renaming "editor hint" to "interaction hint" is deliberate.
  • Trivial reversibility per source. Each source controls its own lifetime via ScopedCompositorHint — no cross-system coordination to "remember to demote."
  • Deterministic resolution. The resolver is a pure function; its behavior is unit-testable and fuzz-testable in isolation, without a live editor or animation clock.
  • Layer budget enforcement in one place. The hard cap (kMaxCompositorLayers, per-backend tighter limits on TinySkia) is checked by a single system, not re-checked at each promotion site.

The alternative — imperative promoteEntity calls from N subsystems — was the v0 direction and is preserved as an escape hatch. It is not the primary mechanism.

Why a shared compositor, not backend-specific

Three arguments:

  1. Correctness is the hard part, not rasterization. The compositor's job is deciding what to rasterize and how to compose. This logic (layer assignment, damage tracking, blend/clip/mask composition order) is identical regardless of whether pixels come from CPU or GPU. A single implementation means one correctness proof, not three.
  2. RendererInterface already abstracts rasterization. The compositor needs: "rasterize these entities into this buffer" and "blit this buffer onto that buffer with this transform/opacity/blend." Both are expressible through existing RendererInterface primitives (beginFrame/endFrame, drawImage, pushIsolatedLayer, setTransform). No new virtuals are needed for v1.
  3. Backend-specific optimizations compose. Skia can internally cache SkPicture recordings per layer. Geode can retain GPU textures across frames. These optimizations live inside the backend's RendererInterface implementation, invisible to the compositor. The compositor doesn't need to know — it just calls drawImage with a RendererBitmap and the backend decides how to upload it.

The risk is that the shared compositor cannot exploit backend-specific composition hardware (e.g., Geode could compose layers via GPU texture blits without CPU readback). This is acceptable for v1 because the bottleneck is rasterization, not composition. v2 can add an optional RendererInterface::composeLayer() fast path that GPU backends override.

Key components

Component Location Responsibility
CompositorController donner/svg/compositor/CompositorController.h Public API. Manages layer backing-store lifecycle, processes dirty flags, orchestrates rasterization and composition. Reads ComputedLayerAssignmentComponent; does not decide promotion on its own.
LayerResolver donner/svg/compositor/LayerResolver.h System. Walks entities with CompositorHintComponent, sums weights, assigns LayerIds subject to budget, writes ComputedLayerAssignmentComponent. Pure function of (hints × dirty flags × budget).
CompositorHintComponent donner/svg/components/CompositorHintComponent.h ECS component. SmallVector<CompositorHint, 4> of {source, reason, weight}. Populated by hint producers via ScopedCompositorHint; drained on scope exit.
ComputedLayerAssignmentComponent donner/svg/components/ComputedLayerAssignmentComponent.h ECS component. {LayerId, winning source}. Written once per frame by LayerResolver. Read by compositor to locate backing stores.
ScopedCompositorHint donner/svg/compositor/ScopedCompositorHint.h RAII handle. Holds (Registry&, Entity, HintId); constructor adds hint, destructor removes it. Moveable, non-copyable.
ComplexityBucketer donner/svg/compositor/ComplexityBucketer.h System. At load / structural rebuild, walks the computed tree, computes per-subtree cost, emits low-weight bucket hints for the top-K non-overlapping subtrees.
CompositorLayer donner/svg/compositor/CompositorLayer.h Represents one layer: backing store (RendererBitmap), entity membership set, dirty flag, cached world-space bounds, opacity/blend/clip metadata.
CompositionTree donner/svg/compositor/CompositionTree.h Draw-order-sorted list of CompositorLayers with composition metadata. Rebuilt when layer membership or z-order changes.
DamageTracker donner/svg/compositor/DamageTracker.h Computes dirty rectangles from layer dirty flags and transform changes.

Data flow per frame

0. [ONCE AT LOAD + on RenderTreeState::needsFullRebuild]
ComplexityBucketer walks the computed tree, computes per-subtree
cost, and publishes low-weight bucket hints on the top-K
non-overlapping subtrees (K ≤ ~4). Mandatory-feature hints are
also emitted here (opacity<1, filter, mask, blend, isolation).
1. Editor drains command queue → marks DirtyFlagsComponent on entities.
In the same pass, any new selections publish/drop interaction hints
via ScopedCompositorHint. Animation system (when present) adjusts
high-weight animation hints on currently-animated subtree roots.
2. createComputedComponents() runs incremental recompute
(style/layout/shape/paint for dirty entities only).
3. LayerResolver::resolveFrame(registry)
a. Iterate entities with CompositorHintComponent.
b. Sum weights per entity; hard-cap at the layer budget.
Tie-breaking: draw order.
c. Write ComputedLayerAssignmentComponent {LayerId, source}.
d. Any entity whose assignment changed this frame is flagged
so the compositor can tear down / spin up backing stores.
4. CompositorController::prepareFrame(registry)
a. Check DirtyFlagsComponent on all entities.
b. For each dirty entity: look up ComputedLayerAssignmentComponent
→ mark that layer dirty.
c. Additionally: any animation-promoted or interactive layer whose
animation clock advanced is also marked dirty even if its own
entities show no DirtyFlagsComponent (the animation system may
publish dirty flags on a tick boundary that prepareFrame must
honor).
d. If assignment set changed (LayerResolver flagged churn): rebuild
CompositionTree.
e. If promoted entity's subtree changed: update layer bounds.
5. For each dirty layer:
a. Create offscreen RendererInterface instance (createOffscreenInstance()).
b. Call RendererDriver::drawEntityRange() for entities in that layer.
c. Store resulting RendererBitmap in CompositorLayer.
(The interactive layer's backing store is pre-allocated at
startup; its rasterization reuses the existing buffer.)
6. Composition pass:
a. beginFrame() on main render target.
b. For each layer in draw order:
- setTransform(layer.compositionTransform)
- If layer has opacity < 1 or blend != Normal:
pushIsolatedLayer(opacity, blendMode)
- drawImage(layer.bitmap, layer.targetRect)
- Pop isolated layer if pushed.
c. endFrame().

Layer Promotion Cascade

Layer assignment is resolved each frame by LayerResolver from weighted hints published by multiple sources. Sources do not call into the compositor directly; they attach CompositorHintComponent entries (via ScopedCompositorHint) and the resolver collapses them.

Weight hierarchy

Source Weight class Typical weight (uint16_t)
────────────────────────────────────────────────────────────
Mandatory Infinite 0xFFFF (non-negotiable)
Animation High 0xC000
Interaction Medium 0x8000
ComplexityBucket Low 0x4000
Explicit Caller-chosen (escape hatch)

Multiple hints on the same entity sum. If total weight exceeds any other entity's total, that entity wins the next contested slot. Ties break by draw order (later wins).

Mandatory hints (SVG semantics force isolation)

These trigger infinite weight and are non-contestable — they always win a slot, even if it means evicting a lower-weight hint. The mandatory-feature detector (a system, not a method call) inspects RenderingInstanceComponent and emits these:

Trigger Detection Reason
opacity < 1.0 RenderingInstanceComponent::isolatedLayer == true Group opacity requires compositing the subtree as a unit, then applying opacity to the result. Without a layer, opacity would apply per-element.
filter applied RenderingInstanceComponent::resolvedFilter.has_value() Filter effects operate on the composited subtree result.
mask applied RenderingInstanceComponent::mask.has_value() Mask compositing requires an intermediate surface.
mix-blend-mode != normal isolatedLayer == true (already triggered by RendererDriver) Non-normal blend modes require group isolation.
isolation: isolate isolatedLayer == true Explicit CSS isolation.

v1 simplification: Mandatory-promotion layers are not cached across frames in v1. They are re-rasterized whenever any element in their subtree is dirty, same as today. The compositor simply avoids re-rasterizing other layers. This is conservative but correct — it matches the current pushIsolatedLayer/popIsolatedLayer behavior exactly.

Animation hints (high weight)

When the animation system detects an active SMIL/CSS animation on a subtree, it publishes a ScopedCompositorHint(root, Animation) on the animated subtree's root for the duration of the animation. The resolver promotes that subtree so per-tick cost is O(subtree), not O(document) (Goal 7).

  • The hint producer is stable even before the animation system exists: mandatory-feature detection can be extended to inspect animation-duration and emit an animation hint from day one.
  • If the animated subtree is already mandatory-promoted (e.g., animating opacity), the animation hint is redundant — the entity already has a dedicated layer. This is the common case.
  • Per-tick dirty marking: when the animation ticks, the animation system sets DirtyFlagsComponent on the subtree root and relies on the standard dirty-flag path. The animation hint only controls where the dirty content lives, not when it is dirty.

Interaction hints (medium weight)

Published by the editor (or any other tool) via ScopedCompositorHint. The compositor does not know what "interaction" means — it is just another hint source. Reserved slot model: LayerResolver pre-allocates one backing store of size viewport × kInteractiveLayerBitmapMultiplier at startup and reuses it across interactions. Interaction promotion therefore does not allocate on the hot path — it only re-rasterizes content into the existing buffer.

Typical hint lifetimes:

Hint reason When published When dropped
InteractionHint::Selection Editor: entity selected Selection cleared
InteractionHint::ActiveDrag Editor: mouse-down on draggable Mouse-up + 500 ms idle

Complexity-bucket hints (low weight)

Emitted by ComplexityBucketer at document load and on structural rebuild. Low weight means they fill whatever slots remain after higher priorities have been assigned. Details in § Complexity Bucketing below.

Explicit promotion (escape hatch)

CompositorController::promoteEntity(entity, reason) still exists and is still a public API. It writes a high-weight Explicit hint with a caller-chosen reason. Used by:

  • Tests that need deterministic layer assignment without going through the cascade.
  • Extensions or plugins with out-of-band knowledge.
  • Emergency workarounds if a bug in the resolver produces the wrong assignment in production.

Production editor code SHOULD NOT use promoteEntity directly. Reviewers should treat new callers skeptically.

Drag fast path (unchanged performance characteristic, different API)

During a drag, after the interaction hint has promoted entity E:

  1. The dragged element's layer is not re-rasterized (only its composition transform changes).
  2. The root / sibling layers are not re-rasterized (nothing in them changed).
  3. Only the composition pass runs: blit all layers at new positions.

This reduces per-frame cost from O(N) rasterization to O(L) blits where L is the number of layers (typically 2 during a drag).

What does NOT get promoted

  • Individual elements inside an already-promoted group. The group's subtree shares one layer.
  • Elements with only color/paint changes — the dirty layer re-rasterizes, but the entity does not get its own layer.
  • Elements inside a deferred-pop stack boundary (clip/mask/filter/ isolation group) that straddles bucket candidates. See § Complexity Bucketing.

(The v0 "elements the user is not interacting with" rule is removed — it contradicted complexity-bucket pre-promotion.)

Complexity Bucketing

At document load (and on RenderTreeState::needsFullRebuild), the ComplexityBucketer system partitions the computed tree into a small number of layers based on per-subtree cost. The goal is Goal 6: kill click-to-first-drag-update latency by pre-rasterizing heavy subtrees into their own layers before the user's first click arrives.

Cost function

Per-subtree cost is computed in a single post-order walk:

cost(node) =
1
+ filter_penalty * has_filter(node)
+ mask_penalty * has_mask(node)
+ Σ cost(child) for child in children(node)

Default penalties: filter_penalty = 16, mask_penalty = 8. These are hand-tuned constants (per Non-Goal 2 — no ML). A filtered subtree counts as if it contained 16 extra elements because filter rasterization is that much more expensive; the constants will be tuned against benchmark results before Phase 2.5 ships, but adaptive cost models are out of scope.

Target count and selection

Target bucket count: K = target_layer_count - reserved_slots, where

  • target_layer_count ≈ 4 on TinySkia (keeps us well under kMaxCompositorLayers = 32 and leaves headroom for animation / interaction / mandatory hints).
  • reserved_slots accounts for the interactive-layer slot (1) and expected mandatory promotions (estimated at load time by counting isolation boundaries; capped at a small constant).

Selection algorithm: greedy — after the post-order walk, sort subtrees by cost descending, iterate in order, promote a subtree iff its bounding box (from ComputedSizedElementComponent) does not overlap any already-promoted subtree and doing so does not split a deferred-pop state. Stop when K subtrees are promoted.

Deferred-pop respect

A candidate subtree is rejected if promoting it would split a clip/mask/filter/isolation group across buckets. Detection reuses the existing deferred-pop stack logic (see § Deferred-pop stack integrity in § Correctness Analysis): walk from the candidate root to the document root; if any ancestor opens a clip/mask/filter/isolation context that is closed outside the candidate subtree, the candidate is ineligible.

When it runs

  • Once at document load, after createComputedComponents() completes.
  • On every RenderTreeState::needsFullRebuild — structural edits invalidate the partition, and the bucketer reruns over the new tree. Per Non-Goal 9, callers must not assume bucket identity is stable across edits.
  • Never per-frame. Dirty-flag-only changes reuse the existing partition.

Why "boring and probably 80% right"

Recursive-median / greedy-descending partitioning is a well-known first pass at spatial / cost-based decomposition. It is deterministic, O(N), and easy to fuzz-test (see § Verification). It is not optimal — a smarter algorithm could reduce composition-pass cost further — but the v1 goal is "fast enough to hide click-to-drag latency on realistic documents," not "optimal partitioning." Non-Goal 7 explicitly excludes adaptive runtime repartitioning.

Damage Tracking

Input: ECS change set

After createComputedComponents(), dirty entities still have their DirtyFlagsComponent attached (cleared at end of frame). The DamageTracker consumes these:

struct DamageInfo {
/// Layers that need re-rasterization.
SmallVector<LayerId, 4> dirtyLayers;
/// Union of old and new screen-space bounds of all dirty layers.
/// Used to limit the composition blit region.
Box2i
damageRect;
/// If true, the entire frame must be recomposed (layer order changed,
/// layer added/removed, or viewport resized).
bool fullRecompose = false;
};

Computing dirty rectangles

For each entity with DirtyFlagsComponent:

  1. Look up ComputedLayerAssignmentComponentLayerId. Mark that layer dirty.
  2. Compute screen-space damage. The damage region is the union of:

    • The entity's previous screen-space bounds (cached on the layer).
    • The entity's current screen-space bounds (from ComputedAbsoluteTransformComponent + shape bounds).

    This handles the case where an element moves: the old position must be repainted (by the layer behind it) and the new position must be painted.

  3. Transform changes on promoted layers are special: if only the WorldTransform flag is set on a promoted element (no Style, Shape, or Paint changes), the layer does not need re-rasterization — only the composition transform changes. This is the drag fast path.

Viewport boundary crossings

When a promoted layer's screen-space bounds exit the viewport, the compositor does not rasterize it (off-screen culling). When it re-enters, the compositor must re-rasterize if the cached bitmap is stale. The CompositorLayer tracks a viewportIntersects flag; transitions from false → true force re-rasterization.

Structural changes (z-order, tree mutations)

If RenderTreeState::needsFullRebuild is set (tree structure changed), the compositor:

  1. Reruns ComplexityBucketer to regenerate bucket hints (§ Complexity Bucketing → When it runs).
  2. Reruns LayerResolver to rewrite all ComputedLayerAssignmentComponents from the fresh hint set.
  3. Rebuilds the CompositionTree from scratch.
  4. Marks all layers dirty.
  5. Falls through to full re-rasterization.

This is the conservative path. It is correct because it degrades to today's behavior. Optimizing structural changes (e.g., only rebuilding affected subtree layers) is future work.

Composition Pass

The composition pass blits cached layer bitmaps onto the final render target. It runs every frame, even when no layers are dirty (because the viewport may have scrolled or the editor overlay needs redrawing).

Layer ordering

Layers are sorted by the minimum drawOrder of their member entities. This preserves SVG paint order.

Composition operations per layer

For each layer in draw order:

1. setTransform(layer.compositionTransform)
// compositionTransform = deviceFromWorld * layer.worldOffset
// For non-promoted layers, this is identity.
// For promoted layers during drag, this incorporates the drag delta.
2. If layer.opacity < 1.0 || layer.blendMode != Normal:
pushIsolatedLayer(layer.opacity, layer.blendMode)
3. If layer.clipPath.has_value():
pushClip(layer.resolvedClip)
4. If layer.mask.has_value():
pushMask(layer.maskBounds)
// render mask content from cached mask layer
transitionMaskToContent()
5. drawImage(layer.bitmap, layer.targetRect)
6. Pop mask/clip/isolated layer in reverse order

Filters during composition

Filters are applied during layer rasterization, not during composition. When an element with a filter is rasterized into its layer, the filter pipeline runs as part of that rasterization (via pushFilterLayer / popFilterLayer on the offscreen RendererInterface). The compositor blits the filter's output — no filter logic in the composition pass.

Clip-path during composition

Clip-paths that apply to a promoted element are resolved during layer rasterization (the offscreen pass clips to the element's clip-path). Clip-paths that apply to a group containing both promoted and non-promoted elements are handled by clipping the composition blit — the compositor applies the group's clip to the blitted layer bitmap.

If a clip-path references geometry that itself changes (e.g., an animated clip), the compositor marks the affected layer dirty for re-rasterization. Clip-path-only changes are not compositable — the layer must be re-rasterized because the clip boundary changed.

Correctness Analysis

This is the critical section. For each rendering feature, we enumerate whether the composited fast path produces identical output, and if not, what safeguard prevents drift.

Affine transforms

Claim: exact. Promoted layers cache rasterized content in the layer's local coordinate space. During composition, the layer bitmap is blitted with the layer's world transform applied via setTransform() + drawImage(). For pure translation (the drag case), this is a pixel-aligned blit — no resampling, no error.

For rotation/scale composition transforms, drawImage() resamples the cached bitmap. This produces output different from rasterizing the vector geometry directly at the final transform because of rasterization-then-transform vs. transform-then-rasterize ordering.

Safeguard: The compositor rasterizes promoted layers at a fixed transform (the layer's world transform at promotion time). If the composition transform diverges from identity by more than pure translation, the compositor marks the layer for re-rasterization at the new transform. During drag, the editor constrains mutations to translation-only transforms. Rotation/scale drags trigger re-rasterization every frame (degrading to full-render cost for that layer, but not for the rest of the scene).

Conservative rule: If compositionTransform is not a pure translation (checked via Transform2d::isTranslation()), re-rasterize the layer. This preserves pixel-identical output at the cost of losing the fast path for non-translational drags.

Perspective transforms

Not applicable. SVG2 does not define perspective transforms. If CSS perspective or transform: perspective(...) is encountered, the parser ignores it (falls through to none). No compositor path needed.

Opacity

Claim: exact for promoted layers, under a constraint.

Group opacity (opacity < 1 on a <g> element) requires compositing the group as a unit, then applying opacity. The compositor handles this by rasterizing the group into its layer at full opacity, then applying opacity during the composition blit via pushIsolatedLayer(opacity). This matches RendererDriver's existing behavior exactly because RendererDriver already uses pushIsolatedLayer for the same purpose.

Constraint: If opacity changes between frames (e.g., animated opacity), the compositor must re-compose with the new opacity value. The layer bitmap does not need re-rasterization — only the composition opacity parameter changes. DirtyFlagsComponent::RenderInstance triggers a recompose but not a re-rasterize when only opacity changed.

Edge case: opacity on a promoted element that is also a drag target. The drag delta is a translation on the composition transform, and opacity is applied during composition. These compose correctly because opacity is multiplicative and translation-invariant.

Clip-path

Claim: exact for static clip-paths. Conservative fallback for dynamic clip-paths.

Static clip-paths (geometry doesn't change between frames) are applied during layer rasterization. The layer bitmap includes the clip — no clip logic during composition. This is exact because the clip is applied to vector geometry before rasterization, identical to the full-render path.

If a clip-path's geometry changes (shape mutation, animated clip), DirtyFlagsComponent::Shape on the clip-path entity triggers re-rasterization of all layers that reference it. This is detected via a reverse-reference map from clip-path entities to their consumers (analogous to the paint-server reverse map in 0005-incremental_invalidation).

Clip-path on a group spanning multiple layers: If a <g clip-path> contains both promoted and non-promoted children, the clip must apply to the composed result. The compositor handles this by:

  1. Composing the group's layers into a temporary buffer.
  2. Applying the clip to the temporary buffer.
  3. Blitting the clipped result to the final target.

This adds one temporary buffer allocation. If this proves too expensive, the fallback is to de-promote children within clipped groups (the group shares a single layer). v1 uses the de-promotion fallback.

Mask

Claim: conservative fallback.

Masks require rendering the mask content, converting to luminance, and applying as alpha. This is inherently a per-rasterization operation. The compositor does not attempt to cache mask bitmaps separately.

Rule: Elements with masks always re-rasterize when marked dirty. The mask is applied during layer rasterization via the existing pushMask / transitionMaskToContent / popMask sequence. This is identical to today's full-render behavior.

Optimization opportunity (deferred): Cache the mask bitmap separately and re-apply during composition when only the masked content changes (not the mask itself). This requires tracking mask dependencies, which adds complexity for marginal gain in v1.

Filter effects

Claim: conservative fallback.

Filter effects (<filter>) operate on the rasterized content of their input. The filter graph executes during layer rasterization via pushFilterLayer / popFilterLayer. The compositor blits the filter's output — it does not re-run filters during composition.

Rule: If any element within a filtered subtree is dirty, the entire layer containing the filter host is re-rasterized (because the filter's input changed). Filter-only changes (e.g., feGaussianBlur stdDeviation animation) also trigger re-rasterization.

Why not cache filter output separately? Filter graphs can have multiple inputs (SourceGraphic, BackgroundImage, other primitives). BackgroundImage depends on content behind the filtered element, which lives in a different layer. Caching filter output across layers requires solving cross-layer dependency tracking — too complex for v1.

mix-blend-mode

Claim: exact. Non-normal blend modes already require isolated layer compositing in SVG. The compositor mirrors this: elements with mix-blend-mode != normal are promoted to their own layer (mandatory promotion), rasterized in isolation, and blitted with the specified blend mode during composition via pushIsolatedLayer(opacity, blendMode). This matches RendererDriver's existing behavior.

Edge case: mix-blend-mode on an element within a promoted drag layer. The blend mode is applied during layer rasterization (the element is rasterized into the drag layer with its blend mode against other elements in that layer). During composition, the drag layer is blitted with Normal blend mode (unless the drag target itself has a non-normal blend mode, in which case the layer is blitted with that mode). This matches ground truth because the same isolation boundary exists in both paths.

Z-order changes

Claim: conservative fallback.

Any tree mutation that changes z-order (insertion, deletion, reordering) sets RenderTreeState::needsFullRebuild. The compositor responds by:

  1. Tearing down the CompositionTree.
  2. Rebuilding layer membership from scratch.
  3. Marking all layers dirty.
  4. Falling through to full re-rasterization + full recomposition.

This is correct because it degrades to today's behavior. It is also rare during interactive editing — drag operations change transforms, not tree structure.

Inherited properties (currentColor, font-size units, text inheritance)

Claim: handled by incremental invalidation, not by the compositor.

Inherited property changes cascade DirtyFlagsComponent::Style to all descendants (per 0005-incremental_invalidation, § Invalidation Propagation Rules). After createComputedComponents() runs, affected entities have updated ComputedStyleComponent values. The compositor sees these entities as dirty and re-rasterizes their layers.

Specific cases:

  • currentColor change on an ancestor: Cascades Style dirty to all descendants. All layers containing those descendants are re-rasterized. Correct because the compositor always re-rasterizes dirty layers with the latest computed styles.
  • font-size change (affects em/ex units in descendants): Cascades Style + Layout dirty. Same path as above.
  • Text inheritance (font-family, text-anchor, etc.): Same path. The compositor is downstream of style resolution and sees only the final computed values.

No compositor-specific logic needed. The correctness argument is: the compositor never uses stale computed styles because it only rasterizes after createComputedComponents() completes.

Overlapping transformed regions

Claim: exact for non-overlapping layers. Conservative fallback for overlapping promoted layers.

When two promoted layers overlap in screen space:

  • If neither is dirty, the cached composition is correct (nothing changed).
  • If one is dirty and re-rasterized, the composition blits in draw order. Because each layer's content is independently rasterized against a transparent background, the composition order matches the full-render paint order. This is exact.
  • If a drag moves a promoted layer to newly overlap with another promoted layer, the composition is still exact because drawImage with the correct blend mode (default: SrcOver) produces the same result as if the elements were painted in that order.

Edge case: overlapping promoted layers with BackgroundImage filter input. BackgroundImage captures content behind the current element. In a composited path, "behind" means previously composed layers — but if those layers were cached and not re-rasterized, the BackgroundImage may be stale.

Rule: Elements using BackgroundImage or BackgroundAlpha filter inputs are never compositable. They force their layer and all layers they depend on to re-rasterize. Detection: scan FilterGraph inputs during promotion for BackgroundImage/BackgroundAlpha references.

Summary table

Feature Fast-path correctness Mechanism
Translation transform Exact Pixel-aligned blit
Rotation/scale transform Exact (re-rasterize) Non-translation detected, layer re-rasterized
Perspective N/A SVG2 doesn't define it
Opacity (static) Exact Applied during composition blit
Opacity (animated) Exact Recompose with new value, no re-rasterize
Clip-path (static) Exact Applied during layer rasterization
Clip-path (animated) Exact (re-rasterize) Dirty flag triggers re-rasterize
Clip-path (cross-layer) Exact (de-promote) Children within clipped group share one layer
Mask Exact (re-rasterize) Always re-rasterize masked layers
Filter Exact (re-rasterize) Always re-rasterize filtered layers
mix-blend-mode Exact Blended during composition, same as RendererDriver
Z-order change Exact (full rebuild) Falls through to full re-render
Inherited properties Exact Dirty flags cascade, layers re-rasterize
Overlapping layers Exact SrcOver composition matches paint order
BackgroundImage filter Exact (never composite) Disables fast path for affected elements
Hit testing during drag Adjusted Compositor transform applied to hit-test path
Layer bitmap sizing Exact Ink rectangle (stroke + markers + filter expansion)
Markers Exact (de-promote or re-rasterize) Marker subtrees included in layer entity range
feImage cross-layer ref Exact (de-promote) Fragment refs targeting other layers force de-promotion
Nested isolation groups Exact (de-promote) Promoted elements inside isolation scope share parent layer
<use> shadow trees Exact (re-rasterize) Dirty cascade from target to shadow instances
Pattern/gradient currentColor Exact (re-rasterize) Reverse-ref map (0005) triggers re-rasterize on change
Deferred-pop stack integrity Exact (de-promote) Groups straddling layer boundaries share one layer

Hit testing during drag

Problem: During drag, the promoted element's composition transform is applied only at composition time — it is NOT reflected in the ECS AbsoluteTransformComponent. Hit-testing uses world-space transforms from ECS, so clicking the visual (dragged) position misses, and clicking the original (pre-drag) position hits an invisible element.

Safeguard: The compositor exposes compositionTransformOf(entity) which the hit-test path composes with the entity's world transform. During drag, the editor's hit-test code queries the compositor for the active composition transform and applies it to the hit-test point (inverse transform the pointer, or forward-transform the element bounds). When compositing is disabled or the entity is not promoted, this returns identity.

Layer bitmap sizing

Problem: The layer bitmap must be large enough to contain the promoted element's full visual extent, including:

  • Stroke width and stroke alignment (stroke-linejoin: miter can extend well beyond the geometry bbox).
  • Marker subtrees (markers extend beyond the path they decorate).
  • Filter expansion (feMorphology, feGaussianBlur, feOffset expand the filter region beyond the geometry bbox).
  • overflow: visible content on <svg>, <symbol>, <pattern>.

Rule: Layer bitmap dimensions = ink rectangle (computed bounding box including all of the above), expanded by the filter primitive subregion if a filter is active. The compositor queries ComputedSizedElementComponent for the base bbox, then applies filter region expansion from ComputedFilterComponent. If the ink rectangle exceeds the viewport, it is clipped to the viewport (content outside the viewport is not visible and does not need rasterization).

Markers

Problem: Marker subtrees (<marker> definitions in <defs>) are instantiated during rasterization at each marker position along the decorated path. The marker definition entity is in <defs> and may be outside the promoted element's entity range.

Safeguard: drawEntityRange() does not need marker definition entities in-range — marker instantiation is handled by RenderingContext which resolves marker references from <defs> regardless of entity range. The layer rasterization step calls drawEntityRange() with the promoted element's range, and the renderer resolves marker references globally. No special handling needed.

However, if the marker definition itself is modified (e.g., a <marker> element's child changes), the dirty flag must cascade to all elements that reference that marker. This is handled by the existing reverse-reference map (design doc 0005). The compositor detects the dirty flag on the marker-using element and re-rasterizes its layer.

feImage cross-layer references

Problem: The feImage filter primitive with href="#fragment" renders the referenced SVG element as the filter input. If the referenced element is in a different compositor layer, the compositor must ensure the referenced layer is rasterized before the filter layer. Additionally, dragging the referenced element changes the filter output without the filter's owning element having a dirty flag.

Safeguard (v1, conservative): If an element has an feImage primitive whose href targets an element in a different compositor layer, de-promote the feImage-owning element (refuse to keep it in a separate layer). This ensures feImage always renders through the full path. The detection is done at promotion time by scanning the element's computed filter graph for feImage nodes with fragment references. This is conservative but correct; v2 can add cross-layer dependency tracking.

Nested isolation groups

Problem: SVG2 and CSS Compositing Level 1 define isolation groups: an element with isolation: isolate or mix-blend-mode != normal creates an isolated stacking context. Blend operations target the nearest isolation group's backdrop, not the page root.

If a promoted element has mix-blend-mode != normal and its nearest isolation ancestor is NOT in the same compositor layer, the blend target is wrong — the element blends against the root layer's content instead of the isolation group's content.

Safeguard: Elements inside an isolation: isolate ancestor that also participate in non-normal blending are not separately promotable. promoteEntity() checks the element's ancestor chain for isolation context boundaries. If found, the element is added to the ancestor's layer (if the ancestor is promoted) or promotion is refused.

For v1, this is a conservative de-promotion rule. The more common case (element with mix-blend-mode that is NOT inside an explicit isolation group) works correctly because the blend target is the page root, which the root compositor layer represents.

<use> shadow trees

Problem: <use> elements create shadow tree clones of their target element. The shadow tree entities are distinct from the target entities, but they inherit styles and may reference the same resources (gradients, patterns, clip-paths) as the target.

Safeguard: Each <use> shadow tree instance is a separate set of entities. If the <use> element is promoted, its shadow tree entities are within the promoted entity range (they are children in the ECS tree). If the <use> target element changes (not the <use> element itself), the dirty flag cascades from the target to all <use> elements referencing it via ShadowTreeComponent dirty propagation. The compositor detects this cascade and re-rasterizes the affected <use> element's layer.

Pattern and gradient currentColor dependency

Problem: currentColor in gradient stops or pattern content resolves to the color property of the element using the paint server. If a promoted element's ancestor's color property changes, the gradient/pattern output changes, but the paint server entity itself may not have a dirty flag.

Safeguard: The reverse-reference map (design doc 0005) tracks paint server → using element dependencies. When color changes on an ancestor, DirtyFlagsComponent::Paint is set on descendant elements that use currentColor-dependent paint servers. The compositor detects this dirty flag and re-rasterizes. Until the reverse-reference map is implemented, this is a known limitation: currentColor changes in gradients/patterns may produce stale layer bitmaps. Workaround: always re-rasterize layers that use currentColor-dependent paint servers.

Deferred-pop stack integrity

Problem: RendererDriver uses a deferred-pop stack for push/pop pairs (clip, mask, isolation, filter). If a group element that pushes state straddles a layer boundary (some children in layer A, some in layer B), the push/pop nesting breaks.

Safeguard: The compositor's de-promotion rule is generalized: any element whose rendering requires push/pop state set by an ancestor outside its layer boundary MUST share the ancestor's layer. This is enforced at promotion time by walking the ancestor chain and checking for active clip-path, mask, filter, and isolation contexts. If any ancestor between the promoted element and the document root has such a context, the promoted element is added to the ancestor's layer or promotion is refused.

In practice, this means elements inside <g clip-path="...">, <g filter="...">, <g mask="...">, or <g style="isolation: isolate"> groups are promoted as part of the group, not individually. This is already the natural behavior for the editor's drag workflow (dragging a group promotes the group).

Animation-time consistency with pixel-identical invariant

Problem: Animation-driven promotion (Goal 7) means an animated subtree is in its own layer, rasterized on each animation tick. The dual-path assertion (full render vs composited) must produce byte-identical output even though the animation clock is advancing. If the full-render path and the composited path sample the animation clock at different times, they will compute different ECS values for the same nominal frame, and the assertion will fail spuriously.

Safeguard: CompositorController::renderFrame() captures the current animation clock value before running either path and re-applies it to both. Both paths use the captured value when resolving animated attributes. The dual-path comparison only runs after both paths have observed the same clock.

This requires the animation system (current or future) to expose a "freeze clock to value T" hook. Whether that hook is explicit (an API on the animation system) or implicit (the clock is a parameter threaded through prepareFrame) is an open question below. Either way, the dual-path assertion MUST cover animation-promoted layers — an assertion that only checks static scenes does not prove Goal 7's correctness.

Partition-boundary correctness

Problem: The complexity bucketer partitions the tree into disjoint subtrees. If it puts a subtree inside a deferred-pop boundary (clip / mask / filter / isolation) into a different bucket than the boundary opener, the rendered output is wrong — the child bucket rasterizes without the clip active, and the parent bucket closes a clip it never opened.

Safeguard: Bucketer eligibility check rejects any candidate whose root-to-document-root ancestor chain crosses a deferred-pop boundary not fully contained in the candidate. This mirrors the existing deferred-pop stack discipline in RendererDriver — layers never split a push/pop pair. Verified by a dedicated test: constructed document with nested <g clip-path> / <g filter> / <g mask> chains, assert bucketer output never splits them.

Compositor-specific golden tests (expanded)

In addition to the base golden tests listed above, add these SpecBot-recommended tests:

  • Promoted element with mix-blend-mode: multiply inside isolation: isolate parent — verify blend target is correct.
  • feImage href="#fragment" where fragment is in a different layer — verify de-promotion and correct rendering.
  • Promoted <path> with marker-mid where marker definition is in <defs> — verify markers render correctly in layer.
  • Promoted element with overflow: visible on ancestor <svg> — verify layer bitmap includes overflow content.
  • <use> element promoted, target element modified — verify dirty cascade triggers re-rasterization.
  • Promoted element with fill: url(#gradient) where gradient uses currentColor and ancestor color changes — verify re-rasterization.
  • Deferred-pop edge case: promote a <rect> inside a <g clip-path="..." mask="..."> group — verify the group constraint forces shared layer.

Backend Integration

Minimum primitive set

The compositor needs these RendererInterface primitives:

Primitive Used for TinySkia Skia Geode
createOffscreenInstance() Layer rasterization into separate buffer ❌ (returns nullptr; needs shared-device constructor)
beginFrame() / endFrame() Frame lifecycle for offscreen and main targets
drawImage() Blit layer bitmap to main target ✅ (GeodeImagePipeline)
setTransform() Composition transform for layer blit
pushIsolatedLayer() / popIsolatedLayer() Opacity/blend during composition ✅ (opacity only; MixBlendMode != Normal pending)
pushClip() / popClip() Clip during composition ✅ (rect + polygon + path-mask clips)
pushMask() / popMask() Mask during layer rasterization ✅ (Phase 3c luminance mask compositing)
pushFilterLayer() / popFilterLayer() Filter during layer rasterization ✅ (via FilterGraphExecutor) ❌ (no-op stub)
takeSnapshot() Extract layer bitmap for cross-layer composition
RendererDriver::drawEntityRange() Rasterize a subset of entities

Geode v1 participation: Geode can participate in compositor testing for the translation-only drag path on scenes without filters or non-normal blend modes. Compositor tests for Geode should be gated behind a feature check that excludes filter/blend-mode cases.

RendererBitmap adapter and alpha model (resolved OQ#1)

The compositor uses drawImage(ImageResource, ImageParams) to blit layer bitmaps. The RendererBitmap from takeSnapshot() must be convertible to an ImageResource. This conversion has correctness and performance pitfalls that must be resolved before implementation.

Problem 1: Premultiplied alpha mismatch. takeSnapshot() returns pixel data in premultiplied RGBA format (TinySkia's internal format; Skia's surface uses the MakeN32 alpha type). drawImage() on all three backends assumes its ImageResource input contains straight (non-premultiplied) alpha and re-premultiplies on ingestion. Feeding premultiplied data through this path double-premultiplies, darkening all semi-transparent content. Additionally, the premultiply→unpremultiply→ premultiply roundtrip introduces ±1 LSB error per channel, violating the threshold=0 correctness goal.

Problem 2: Color type mismatch. Skia's MakeN32 uses the platform's native 32-bit color type (kBGRA_8888 on desktop Linux), but drawImage declares kRGBA_8888. If these differ, red and blue channels swap.

Problem 3: Allocation cost. The generic takeSnapshot()drawImage() path involves 2–4 full-resolution copies per layer blit (66–132 MB of allocation per frame at 4K for 2 layers). At 60 fps, this produces ~4–8 GB/s of heap allocation churn.

Resolution: Two-tier adapter.

Tier 1 (v1, all backends): Add an AlphaType enum and colorType field to RendererBitmap. Add a premultiplied flag to ImageResource. When drawImage receives premultiplied data, skip the premultiply step. Normalize takeSnapshot() output to canonical premultiplied RGBA on all backends. This eliminates the correctness bugs and halves the allocation cost (removing the premultiply copy).

enum class AlphaType : uint8_t { Premultiplied, Unpremultiplied };
struct RendererBitmap {
Vector2i dimensions;
std::vector<uint8_t> pixels;
std::size_t rowBytes = 0;
AlphaType alphaType = AlphaType::Premultiplied;
};

Tier 2 (v2, per-backend fast path): Add a LayerHandle abstraction to RendererInterface that bypasses RendererBitmap entirely:

struct LayerHandle {
virtual ~LayerHandle() = default;
};
virtual std::unique_ptr<LayerHandle> retainCurrentFrame() { return nullptr; }
virtual void drawLayer(const LayerHandle& handle, const ImageParams& params) {}

Skia implements retainCurrentFrame() via surface->makeImageSnapshot() (zero-copy COW SkImage). Geode retains the GPU texture handle. TinySkia accesses the offscreen frame_ pixmap directly via PixmapView (no takeSnapshot() copy). Each backend gets its native fast path.

TinySkia-specific optimization: For TinySkia, the compositor can add an internal blitFrom(const RendererTinySkia& source, ...) method that composites the offscreen frame_ pixmap directly via Painter::drawPixmap on the premultiplied PixmapView, eliminating all copies. This does not change RendererInterface — it is a TinySkia-internal friend function.

No new RendererInterface virtuals in v1. The tier-1 adapter changes only existing struct fields. The tier-2 LayerHandle virtuals are a v2 addition.

Skia-specific considerations

Skia's SkPicture recording could cache draw commands per layer and replay them without re-traversing the ECS. However, this is only valuable if combined with sub-layer dirty rectangles (Phase 4): for whole-layer re-rasterization, the compositor's bitmap cache already skips both ECS traversal and rasterization for clean layers, making SkPicture redundant. Phase 3's Skia optimization is gated on Phase 4.

takeSnapshot()drawImage() path: Skia's internal composition (e.g., popFilterLayer, popMask, endPatternTile) uses surface->makeImageSnapshot()canvas->drawImage() — a zero-copy path via copy-on-write SkImage. The compositor's v1 path goes through takeSnapshot() (CPU readback) → ImageResourcedrawImage() (re-upload), which involves 4 full-resolution copies. This is acceptable for v1 (bottleneck is rasterization, not composition), but v2 should add a LayerHandle abstraction that Skia implements via SkImage COW.

saveLayer allocation during composition: pushIsolatedLayer() maps to saveLayer(nullptr, &paint), which allocates a full-canvas-sized offscreen bitmap. During composition blits (one bitmap per layer), this is wasteful — opacity and blend mode can be applied directly on the SkPaint passed to drawImageRect(). The v1 impact is manageable (sequential composition limits peak to 1 extra saveLayer at a time), but the memory budget (§ Security) must account for it.

Geode-specific considerations

Geode renders to GPU textures via GeoSurface. Layer bitmaps can be retained as GPU textures across frames, avoiding the CPU readback path (takeSnapshot()drawImage()). In v1, the compositor goes through the CPU readback path for simplicity. v2 can add a LayerHandle abstraction to RendererInterface (see § v2 Layer Handle below).

createOffscreenInstance() prerequisite: Geode does not currently override createOffscreenInstance() (returns nullptr from the base class default). Implementation requires a shared-device constructor: offscreen instances share the parent's GeodeDevice and pipeline state objects (GeodePipeline, GeodeGradientPipeline, GeodeImagePipeline) while maintaining their own GeoEncoder and texture pair per render target. This matches the existing pushIsolatedLayer pattern, which already creates new encoder+texture pairs on the shared device. Pipeline state objects are device-scoped in WebGPU and can be reused across any number of render targets.

Geode's current drawImage implementation (GeodeImagePipeline + GeodeTextureEncoder::drawTexturedQuad) is already designed for pre-uploaded wgpu::Texture handles. The v2 LayerHandle adapter would skip the CPU readback and pass the GPU texture directly via surface->makeImageSnapshot() (Skia) or texture handle retention (Geode).

Editor Integration

API surface

namespace donner::svg::compositor {
/// Source of a compositor hint. The compositor does not know which
/// subsystem is behind each source — it only reasons about weights.
enum class HintSource : uint8_t {
Mandatory, ///< SVG semantics (opacity<1, filter, mask, blend).
Animation, ///< Active SMIL/CSS animation on subtree.
Interaction, ///< User interaction (selection, drag) — published
///< by editor, but source-agnostic.
ComplexityBucket, ///< ComplexityBucketer at load / structural rebuild.
Explicit, ///< Escape hatch via promoteEntity().
};
enum class InteractionHint : uint8_t {
Selection, ///< Element is selected.
ActiveDrag, ///< User actively dragging.
// Hover is intentionally not exposed in v1 — see Non-Goal 8.
};
/// RAII handle. Publishing a hint adds it to the entity's
/// CompositorHintComponent; drop the handle, hint disappears, and
/// the entity demotes automatically once no other hints remain.
public:
HintSource source, uint16_t weight,
std::uint32_t reason = 0);
ScopedCompositorHint& operator=(const ScopedCompositorHint&) = delete;
};
/// Public compositor API. Most callers should use ScopedCompositorHint
/// instead of the explicit promote/demote APIs below.
class CompositorController {
public:
explicit CompositorController(Registry& registry);
/// Escape-hatch explicit promotion. Production code paths should use
/// ScopedCompositorHint instead. Left in the API for tests and
/// unusual extensions that need deterministic layer assignment.
void promoteEntity(Entity entity, std::uint32_t reason);
void demoteEntity(Entity entity, std::uint32_t reason);
/// Update the composition-time transform for a promoted layer.
/// This is the fast path: no re-rasterization, only changes the
/// blit transform during composition. Callable regardless of what
/// hint source promoted the entity.
void setLayerCompositionTransform(Entity entity,
const Transform2d& compositionTransform);
/// Prepare the frame: run LayerResolver, consume dirty flags, mark
/// dirty layers. Must be called after createComputedComponents().
DamageInfo prepareFrame();
/// Rasterize dirty layers and compose the final frame.
/// Calls into RendererInterface.
void renderFrame(RendererInterface& renderer,
const RenderViewport& viewport);
/// Query whether compositing is active (at least one promoted layer).
[[nodiscard]] bool isActive() const;
};
} // namespace donner::svg::compositor
RAII handle that adds a HintEntry to an entity's CompositorHintComponent on construction and removes ...
Definition ScopedCompositorHint.h:40
uint16_t weight() const
Returns the hint weight. Undefined if the handle has been moved from.
Definition ScopedCompositorHint.h:90
Entity entity() const
Returns the entity this handle targets. Returns entt::null if the handle has been moved from.
Definition ScopedCompositorHint.h:84
HintSource source() const
Returns the hint source. Undefined if the handle has been moved from.
Definition ScopedCompositorHint.h:87
ScopedCompositorHint(Registry &registry, Entity entity, HintSource source, uint16_t weight)
Attach a hint to entity.
entt::basic_registry< Entity, std::allocator< Entity > > Registry
Registry type for the SVG ECS, which is the entry point for storing all data.
Definition EcsRegistry.h:51
entt::entity Entity
Entity type for the Registry, a std::uint32_t alias.
Definition EcsRegistry.h:21
Transform2< double > Transform2d
Shorthand for Transform2<double>.
Definition Transform.h:334

Editor interaction workflow (scoped-hint model)

1. Select(E) — user clicks element E:
SelectTool constructs a member:
ScopedCompositorHint selectionHint_(registry, E,
HintSource::Interaction, kInteractionWeight,
std::to_underlying(InteractionHint::Selection));
Next frame, LayerResolver promotes E to the reserved interactive
layer slot. Backing store is reused — no allocation.
Rasterization cost: one offscreen rasterize of E (the reserved
slot is large enough to contain it).
2. DragStart(E) — user begins dragging:
SelectTool swaps the selection hint for an active-drag hint (or
stacks another hint — both work). No layer lifecycle change,
because E is already on the interactive layer from step 1.
Latency: Goal 6 (p50 < 16 ms from Select to first drag frame).
3. Mouse move (drag delta = dx, dy):
editor calls CompositorController::setLayerCompositionTransform(
E, Transform2d::Translate(dx, dy)).
No rasterization. Only composition transform updated.
renderFrame() blits other layers + E's layer at offset (dx, dy).
4. DragEnd(E) — user releases:
editor calls EditorApp::applyMutation(TransformSet{E, finalTransform})
→ DirtyFlagsComponent::Transform set on E
→ createComputedComponents() updates E's world transform.
If E is still selected, the Selection hint holds E on the
interactive layer. If E is deselected, the hint goes out of scope
(member destructor runs) and LayerResolver demotes E on the next
frame (backing store returned to the reserved pool — not freed).
5. Deselect(E):
selectionHint_ goes out of scope → hint removed from
CompositorHintComponent → LayerResolver sees no remaining hints
above bucket weight → E drops back to its bucket-assigned layer.

Naming note: source-agnostic enums

The prior draft had PromotionReason::EditorHint. That name leaks a subsystem boundary into the compositor. Renamed to InteractionHint (and grouped under HintSource::Interaction). Any future tool that wants to promote a "thing the user is focused on" publishes an interaction hint — the compositor is indifferent to whether the caller is the editor, a diagnostic overlay, or a hypothetical accessibility tool.

Promotion during interaction does not trigger full re-render

When E is promoted (step 1), the other layers must be re-rasterized without E. For the interactive-layer case this is one full layer rasterization (the slot is reserved — no allocation). For the complexity-bucketed case, the bucket that used to contain E loses its contribution and is marked dirty. Cost:

  • Selection start: 1× rasterization of E into the reserved slot + 1× re-rasterization of the layer E used to live in.
  • Drag frames: 0× rasterization (composition only).
  • Selection end: 1× re-rasterization of the returning-bucket layer.

For a 10,000-element scene at ~200 ms per full render: with complexity bucketing (Goal 8), the "layer E used to live in" is a bucket of ~2,500 elements, so selection-start cost is ~50 ms on TinySkia — well below the p99 33 ms budget only if the selection was predicted by a bucket. If E falls into an unfortunate bucket (large, heavy filters), selection-start can still frame-drop. The complexity bucketer targets this case by splitting the tree into balanced buckets at load, so no single bucket dominates.

All intervening drag frames are <1 ms (composition only). This is the target behavior.

Verification Strategy

Pixel-diff in tests

Every `renderer_tests` and `resvg_test_suite` test case gains a second execution mode: render via the composited path with a trivially promoted root layer (all elements in one layer). Compare against the ground-truth full-render. Threshold: `maxDiffPixels=0, threshold=0`.

This runs in CI on every PR. It catches any compositor bug that produces different output from the full render.

Dual-path debug assertion

When enabled via the Bazel flag `--//donner/svg/compositor:dual_path_assertion=true` (or `--config=compositor-debug`), `CompositorController::renderFrame()` runs *both* the composited path and a full re-render, then compares the results pixel-by-pixel. On mismatch, it:

  1. Logs the differing pixel coordinates and values.
  2. Writes both bitmaps to a temp directory for inspection.
  3. Fires `UTILS_RELEASE_ASSERT_MSG` with a descriptive message.

This is expensive (2× render cost) and is always enabled in CI compositor test targets. It is NOT enabled globally in debug builds (the per-frame cost would make interactive debugging unusable). Developers opt-in locally via `--config=compositor-debug`.

**Snap to integer pixels:** During composition, all translation offsets are snapped to integer device pixels before the blit. This prevents sub-pixel filtering from introducing differences between the composited and full-render paths. The snap is performed as `round(offset)` in device-pixel coordinates.

Property-test-style random scenes

A new test target (`compositor_fuzz_tests`) generates random SVG scenes with:

  • Random element count (1–500).
  • Random transforms (translate, rotate, scale).
  • Random opacity (0.0–1.0).
  • Random clip-paths and masks.
  • Random promotion of 1–5 elements.
  • Random drag transforms on promoted elements.

For each scene:

  1. Render via full path → bitmap A.
  2. Render via composited path → bitmap B.
  3. Assert A == B (within AA tolerance).

This runs as a long-running fuzzer, not in per-PR CI. It explores the space of compositor edge cases that hand-written tests miss.

Compositor-specific golden tests

Dedicated test cases for the correctness edge cases enumerated above:

  • Overlapping promoted layers with different blend modes.
  • Promoted element with animated opacity.
  • Promoted element within a clipped group.
  • Promoted element with a filter.
  • Drag that causes viewport boundary crossing.
  • `currentColor` change on ancestor of promoted element.

Each test renders both paths and asserts pixel identity.

Hint-cascade and auto-promotion tests (Phase 2)

  • **`animation_isolation_test`** (Goal 7): 10k-node scene with one animated `<rect> (SMIL <animateTransform type="translate"> over 2 seconds). Assert (a) the animated subtree is on its own layer (queryable via ComputedLayerAssignmentComponent), (b) non-animated subtree layers are not re-rasterized across frames (observable via CompositorLayer::rasterizationCount()), and (c) composited output is pixel-identical to the full-render path across 60 frames.
  • **click_to_first_drag_update_benchmark** (Goal 6): 10k-node scene, construct ScopedCompositorHint(E, Interaction, Selection) followed within 1 frame by a setLayerCompositionTransform drag delta; measure wall-clock from hint construction to first composited frame containing E at its new position. Assert p50 < 16 ms, p99 < 33 ms on CI hardware.
  • **bucket_partition_determinism_test** (Goal 8): load the same document 10 times, each time capture the ComputedLayerAssignmentComponent values for every entity, assert all 10 captures are identical. Catches nondeterminism in the cost function or the greedy selection order.
  • **bucket_boundary_respect_test**: constructed document with nested <g clip-path="...">, <g filter="...">, <g mask="...">, <g style="isolation: isolate"> chains. Assert ComplexityBucketer never places children of these groups in a different bucket from the group itself (deferred-pop correctness, § Correctness Analysis).
  • **animation_dual_path_test**: dual-path assertion with an active animation. The animation clock is frozen to a shared value, both paths run, pixel-identical output asserted. This is the Goal-7 equivalent of the existing dual-path assertion; it must be in Phase 2 before animation-driven promotion ships.
  • **interaction_hint_no_allocation_test**: measure heap allocations between ScopedCompositorHint construction and first composited frame. Assert 0 allocations on the hot path (the reserved interactive-layer backing store is pre-allocated at startup).
  • **hint_cascade_resolver_fuzz**: fuzzer that emits random valid CompositorHintComponent` configurations (1–50 entities, 1–5 hints each, random weights within class bounds) and asserts the resolver (a) never exceeds the layer budget, (b) produces identical output for identical input, and (c) never assigns two overlapping subtrees to different layers unless forced by mandatory hints.

Perf-gate waivers (v1)

The v1 CompositorPerfTest and AsyncRendererE2ETest suites gate per-frame cost, but at measurement thresholds, not at the aspirational targets from this design doc. The aspirational values (recorded throughout Goals § and this section — p50 < 16 ms, p99 < 33 ms, 60Hz fluid drag, click → first pixel < 100 ms) are the targets; the gates are ~2-3× the numbers GitHub's shared ubuntu-latest and macos-latest runners reliably hit today. The gap is the v1 cost that hasn't been optimized out yet — primarily recomposeSplitBitmaps on the first promote and the per-segment dirty walk on every drag frame.

Test Aspirational v1 gate Observed (CI) Gap attribution
DragFrameOverhead_1kNodes < 1 ms/frame 30 ms ~12 ms Per-frame compositor overhead includes segment dirty walk + ComplexityBucketer::reconcile — both O(entities).
DragFrameOverhead_10kNodes < 5 ms/frame 350 ms ~64-135 ms Same root cause as 1k, scaled.
ClickToFirstDragUpdate_10kNodes dragMs < 100 ms 300 ms ~60-135 ms First drag frame still pays the segment-dirty walk.
ClickToFirstDragUpdate_10kNodes combinedMs < 1500 ms 4000 ms ~810-2115 ms Cold instantiateRenderTree + prewarm rasterize are O(entities); dominant at 10k.
ClickToFirstDragUpdate_1kNodes combinedMs < 200 ms 650 ms ~80-250 ms Same, scaled.
kClickToFirstPixelBudgetMs < 100 ms 1000 ms ~150-490 ms recomposeSplitBitmaps on first promote composites bg/fg from segments + non-drag layers.
kDragFrameBudgetMs < 8 ms (120Hz) 40 ms ~7-20 ms Worker-side compose + takeSnapshot per drag frame.
FaithfulFrameDragOnRealSplash steady-avg < 16 ms (60Hz) 75 ms ~39 ms Worker compose + overlay rasterize per drag frame, including HiDPI scale.

The gates still catch real regressions — a full re-rasterize every frame would trip every one of them by order-of-magnitude, and the single-line drawEntityRange composition-order bug fixed during this PR (see § "Milestone 0.6" in design doc 0030) would have broken the tight-bounds golden, not these perf gates. Tightening back toward the aspirationals is tracked as:

  • First-promote latency — eliminate recomposeSplitBitmaps by having the editor consume raw segments + layer textures instead of pre-composited bg/fg. Cross-cutting editor refactor; brings kClickToFirstPixelBudgetMs back under 100 ms.
  • Per-frame dirty walk — cache RenderingInstanceComponent::localDrawBounds at RIC instantiation time (design doc 0030, Milestone 2) so the per-segment bounds check is a single cache-line read. Brings DragFrameOverhead_* back toward the 1/5 ms targets.
  • Cold prewarmClickToFirstDragUpdate_10kNodes prewarm is dominated by first-ever style cascade. Tracked separately as "incremental style invalidation" (design 0005).

The comments in CompositorPerf_tests.cc / AsyncRenderer_tests.cc next to each EXPECT_LT call out the aspirational target so future perf work knows where to tighten back.

Security and Trust Boundaries

Memory budget

Each compositor layer is a full-resolution RGBA bitmap. Budget:

Viewport Bytes/layer 8 layers 32 layers
1920×1080 (1080p) 8.3 MB 66 MB 266 MB
2560×1440 (1440p) 14.7 MB 118 MB 471 MB
3840×2160 (4K) 33.2 MB 265 MB 1,061 MB

Hard limits (compile-time constants, overridable via Bazel defines):

constexpr int kMaxCompositorLayers = 32;
constexpr size_t kMaxCompositorMemoryBytes = 256 * 1024 * 1024; // 256 MB

When either limit is reached, promoteEntity() returns false and the compositor falls back to full rendering for that entity. Layer count is O(promoted entities), not O(SVG elements), so the limit is unlikely to be hit in normal editor usage (typically 1–3 promoted layers during drag).

Bitmap ownership model

  • CompositorLayer owns its rasterized RendererBitmap (via std::vector<uint8_t> inside the bitmap struct).
  • During composition, the compositor passes a non-owning RendererBitmap reference to drawImage(). The reference is valid only for the duration of the composition call.
  • takeSnapshot() returns a newly-allocated bitmap with its own std::vector<uint8_t>. Ownership transfers to the caller (CompositorLayer).
  • No shared ownership. No reference counting on bitmaps. The compositor is single-threaded within a frame.

Entity validity

CompositorLayer stores an Entity handle. The ECS registry may invalidate entities (e.g., via removeEntity()). The compositor must validate entity existence before accessing components:

if (!registry_.valid(layer.entity())) {
demoteLayer(layer);
return;
}

Stale entity handles are a logic error, not a security boundary, but the validation prevents undefined behavior from dangling entity references. demoteEntity() on an already-invalid entity is a no-op.

Trust boundary: untrusted SVG input

The compositor does not introduce new trust boundaries beyond those already enforced by the parser and renderer. Specifically:

  • Layer count is bounded by kMaxCompositorLayers, preventing unbounded memory growth from pathological promotion patterns.
  • Bitmap dimensions are clamped to viewport size (the compositor never allocates larger-than-viewport bitmaps).
  • The compositor does not interpret SVG content — it operates on the already-validated ECS component graph produced by the parser.

Implementation Phases

Phase 1: Minimum viable compositor (v1)

Goal: Fluid drag of ONE promoted shape with correctness guarantees.

Prerequisites (must exist before implementation begins):

  • Transform2d::isTranslation() — add to donner/base/Transform.h. Returns true when a() == 1 && b() == 0 && c() == 0 && d() == 1 (i.e., only e and f translation components are non-zero).
  • AlphaType enum and premultiplied flag on RendererBitmap — required for correctness of the takeSnapshot()drawImage() path (see § RendererBitmap adapter).
  • createOffscreenInstance() override in at least TinySkia and Skia (already implemented). Geode override is optional for v1 (gated).

Implementation steps (correctness-first order):

  1. Create donner/svg/compositor/ package with Bazel targets.
    • Verify: bazel build //donner/svg/compositor/... succeeds.
  2. Implement CompositorLayer with bitmap cache and dirty tracking.
    • Verify: Unit test: construct layer, set dirty, verify state.
  3. Implement CompositorHintComponent and ComputedLayerAssignmentComponent ECS components plus the ScopedCompositorHint RAII handle.
    • Verify: Unit test: construct/destroy scoped hints, assert the hint component contents match and that the component is removed when the last hint drops.
  4. Implement minimal LayerResolver supporting Mandatory and Explicit sources only. Writes ComputedLayerAssignmentComponent. Enforces kMaxCompositorLayers.
    • Verify: Unit test with hand-constructed hint sets: mandatory always wins; explicit wins over nothing; budget cap respected; determinism across 10 runs.
  5. Implement CompositorController with escape-hatch promoteEntity / demoteEntity (writing Explicit hints).
    • Verify: Unit test: promote/demote, verify layer creation/destruction and ComputedLayerAssignmentComponent lifecycle.
  6. Wire dual-path debug assertion harness (before any fast path).
    • Verify: With assertion enabled, full-render == composited for a trivial scene (single rect, no promotion). This proves the assertion infrastructure works before fast-path code lands.
  7. Implement prepareFrame(): run LayerResolver, consume DirtyFlagsComponent, mark dirty layers.
    • Verify: Unit test: modify entity, verify layer marked dirty; mandatory-hint entity gets its own layer.
  8. Implement layer rasterization via createOffscreenInstance() + drawEntityRange().
    • Verify: Integration test: explicit-promote one rect, rasterize its layer, pixel-diff against full render of just that rect.
  9. Implement composition pass: blit layers via drawImage().
    • Verify: Integration test: full scene with one explicit-promoted rect, composited output == full render (threshold=0).
  10. Implement setLayerCompositionTransform() for translation-only drag.
    • Verify: Integration test: translate promoted rect by (10, 20), composited output == full render with translated rect.
  11. Wire editor drag workflow (Phase 1 uses the explicit-promotion escape hatch; scoped-hint migration lands in Phase 2): promote on mouse-down, update transform on move, demote on up.
    • Verify: Editor integration test: simulate drag sequence, verify promote/demote lifecycle and frame output.
  12. Performance benchmark: 10k-element scene, single-element drag, measure per-frame time.
    • Verify: Assert < 16.67 ms on CI hardware (Apple M1 or equivalent).

Phase 1 scope note: Phase 1 includes the CompositorHintComponent, ComputedLayerAssignmentComponent, ScopedCompositorHint, and a minimal LayerResolver from day one. The resolver ships with only the Mandatory and Explicit sources wired up; Animation, Interaction, and ComplexityBucket sources land in later phases. This means the dual-path assertion covers the hint cascade from day one (no retrofit), and the escape-hatch promoteEntity API routes through the resolver rather than bypassing it.

Phase 2: Hint cascade (mandatory + interaction + animation)

Prerequisites: Phase 1 complete. The hint cascade plumbing exists and carries Explicit + Mandatory hints.

  • Add HintSource::Interaction and InteractionHint enum.
  • Reserve the interactive-layer backing store at startup.
  • Migrate editor from promoteEntity to ScopedCompositorHint. (Routed internally — CompositorController::promoteEntity publishes an Interaction hint under autoPromoteInteractions, falling back to the Explicit escape hatch otherwise. Editor API-surface unchanged.)
  • Editor publishes InteractionHint::Selection on selection, InteractionHint::ActiveDrag on drag start. (Prewarm path in RenderCoordinator tags with Selection; active-drag path tags with ActiveDrag. Hover intentionally not in the v1 enum per Non-Goal 8.)
  • Add HintSource::Animation. The mandatory-feature detector is extended to inspect animation state (or, when the future animation system lands, it publishes hints directly). (ScopedCompositorHint::Animation factory exists; no producer until the animation system lands.)
  • Layers sorted by draw order after reconciliation (CompositorController::reconcileLayers). Precondition for full multi-layer composition.
  • CompositedPreview transport shape for multi-layer scenes. The current shape (backgroundBitmap / promotedBitmap / foregroundBitmap) models exactly one drag layer; under bucketing there can be K+1 layers. Open design question: extend to a list or route multi-layer through the full composited bitmap. This blocks flipping complexityBucketing default-on.
  • Cross-layer clip-path handling (de-promote within clipped groups).
  • Compositor golden tests for all correctness edge cases.
  • click_to_first_drag_update_benchmark (baseline: 5.4 ms combined on 1k-node scene; 70.2 ms combined on 10k-node scene, mock renderer, --compilation_mode=opt).
  • animation_isolation_test, animation_dual_path_test, interaction_hint_no_allocation_test.
  • Dual-path assertion enabled in CI compositor test targets.
  • Runtime field CompositorConfig::autoPromoteInteractions gates interaction hints (editor publishes ScopedCompositorHint::Interaction only when this is true).
  • Runtime field CompositorConfig::autoPromoteAnimations gates animation-driven promotion independently. (Field exists; no producer yet so the gate is effectively unreachable.)

Phase 2.5: Complexity bucketing at load

Prerequisites: Phase 2 complete. Independent rollback flag.

  • Implement ComplexityBucketer system.
  • Run bucketer at document load and on RenderTreeState::needsFullRebuild. (Wired into CompositorController::renderFrame behind the documentDirty gate so it only rescans on structural change; default-off until the CompositedPreview transport shape is resolved.)
  • Low-weight HintSource::ComplexityBucket hints published.
  • bucket_partition_determinism_test (see complexity_bucketer_tests, 11 cases covering empty registry, root-only, within-budget, over-budget eviction, filter/mask cost dominance, idempotence, stale cleanup, determinism across 10 runs, reserved-slots, zero-budget edge).
  • bucket_boundary_respect_test (full deferred-pop validation — v1 sidesteps this by limiting candidates to top-level root children; deeper candidates deferred).
  • Measure load-time overhead; assert < 5% of parse+ECS-build (Goal 8).
  • Runtime field CompositorConfig::complexityBucketing gates bucketing independently of Phases 1/2 (default false in v1 — see Phase 2 CompositedPreview open question).

Phase 3: Backend optimizations (deferred)

  • Implement LayerHandle abstraction (§ RendererBitmap adapter, tier 2).
    • Skia: SkImage COW via surface->makeImageSnapshot().
    • Geode: GPU texture retention, zero-copy drawLayer().
    • TinySkia: internal blitFrom() on premultiplied PixmapView.
  • Skia: SkPicture recording per layer (gated on Phase 4 sub-layer invalidation).
  • Pool RendererBitmap allocations — reuse across takeSnapshot() calls.

Phase 4: Advanced features (deferred)

  • Sub-layer dirty rectangles (tile-based invalidation within a layer).
  • Thread-pool layer rasterization.
  • Mask bitmap caching.
  • Filter output caching.
  • Adaptive / runtime-cost-driven bucketing (supersedes the earlier "automatic layer merging" bullet — merging is a special case of adaptive bucketing and should be designed together).
  • CSS will-change auto-promotion. Still deferred, and still advisory-only in v1 per Non-Goal 4.
  • InteractionHint::Hover. Deferred to v2 per Non-Goal 8; not exposed in the v1 enum.

Open Questions

  1. RendererBitmapImageResource adapter.Resolved: see § RendererBitmap adapter and alpha model above. Two-tier approach: v1 adds AlphaType flag to RendererBitmap and premultiplied flag to ImageResource; v2 adds LayerHandle abstraction.
  2. Promotion cost amortization. Promoting an element requires re-rasterizing the root layer without it and rasterizing the promoted layer. For large scenes, this is 2× the full-render cost on the first frame. Should the compositor pre-promote likely drag targets (e.g., elements under the cursor) to hide this latency?
  3. Layer bitmap resolution. Should layers be rasterized at viewport DPI or at a higher resolution for quality during zoom? Higher resolution wastes memory; viewport DPI re-rasterizes on zoom. v1: viewport DPI, re-rasterize on zoom.
  4. Geode offscreen rendering.Resolved: see § Geode-specific considerations above. Shared-device constructor with own GeoEncoder and texture pair per render target.
  5. Memory budget.Resolved: see § Security and Trust Boundaries above. Hard limits: kMaxCompositorLayers = 32, kMaxCompositorMemoryBytes = 256 MB. Promotion refused when limits reached; fallback to full rendering.
  6. Editor overlay interaction. The editor's OverlayRenderer draws selection chrome after the document via direct RendererInterface calls. Should the compositor be aware of the overlay, or should the overlay draw on top of the composed frame? (Likely the latter — the overlay is not part of the SVG document and should not participate in layer management.)
  7. BackgroundImage filter detection. How commonly is BackgroundImage used in real SVGs? If it is rare, disabling compositing for it is cheap. If it is common, we need a better strategy.
  8. Pre-chunking cost budget on 10k-node cold boot. Goal 8 caps load-time bucketing overhead at 5% of parse + ECS-build wall clock. Is that the right tradeoff? A tighter cap (say 2%) forces a simpler algorithm and gives up some layer quality; a looser cap (say 10%) makes cold boot visibly slower on low-end devices but lets the bucketer do more work. 5% is a guess; the answer should come from a measurement on representative documents before Phase 2.5 ships.
  9. Animation-time consistency — freeze-clock hook.Resolved: implicit. The frame's animation clock is sampled once per frame before createComputedComponents() runs, so both the full-render and composited paths naturally observe the same animation time within a single renderFrame call. No explicit freezeClock API is added — that would leak testing concerns into the production animation-system surface before the animation system exists. If a future test needs to pin the clock deterministically, it sets the clock on the animation system directly, not through a compositor hook.
  10. Selection signal latency budget — end-to-end. Goal 6 states p50 < 16 ms, p99 < 33 ms from ScopedCompositorHint construction to first composited frame containing E. But what does "construction" mean if the editor publishes the hint from its input thread while the frame is drained on the render thread? The budget currently assumes same-thread publication. Is that still true after any future threading changes? Should the budget have an explicit "assumes main-thread hint publication" clause (which Non-Goal 3 already implies)?
  11. Bucket-boundary mutations — repartition policy. When the document is edited, structural mutations trigger RenderTreeState::needsFullRebuild, which reruns the bucketer (§ Complexity Bucketing). But what about non-structural edits that shift per-subtree cost significantly (e.g., adding a filter to a <g> without adding nodes)? Do we repartition on a cost-delta threshold, or accept stale assignments until the next structural rebuild? Non-Goal 7 leans toward "accept stale," but this needs an explicit rule.
  12. Undo ordering — compositor hint lifecycle. Undo can both restore a prior selection and reverse a structural mutation. Does the compositor see this as two signals (structural rebuild invalidates bucketing; hint restoration re-promotes) or one (undo transaction drives both atomically)? The former is simpler to implement but may produce a transient frame with the wrong layer assignment. The latter requires an EditTransaction boundary the compositor is aware of.
  13. Hover hints in v1.Resolved: omit entirely. See Non-Goal 8. InteractionHint::Hover is not part of the v1 enum; whether v2 adds it as a zero-weight placeholder or with real weight is a v2 decision informed by measured latency-vs-thrash tradeoffs in shipping user sessions.

Alternatives Considered

A. No compositor — PGO the full-render path

Approach: Use profile-guided optimization to make the full-render path fast enough for 60 fps interaction on 10k-node scenes.

Why it doesn't work: The full-render path is O(N) in scene complexity by construction — every element is rasterized every frame. PGO can reduce constant factors (maybe 2–3×) but cannot change the algorithmic complexity. A 10k-node scene at ~200 ms today might reach ~80 ms with PGO — still above the 16 ms budget. The compositor reduces interactive frames to O(L) where L is the number of layers (typically 2), which is fundamentally different.

PGO is still valuable within the compositor (reducing layer rasterization cost) and should be pursued independently.

B. Backend-specific compositors

Approach: Each backend implements its own compositor. Skia uses SkPicture + SkSurface tile cache. Geode uses GPU render-to-texture

  • texture atlas. TinySkia uses software blit.

Why it doesn't work well:

  1. Triples the correctness surface area. Three compositor implementations means three sets of edge cases, three sets of tests, and three chances to drift from ground truth.
  2. The hard part (layer assignment, damage tracking, composition order) is backend-independent. Only the rasterization and blit steps differ, and those are already abstracted by RendererInterface.
  3. Geode's compositor would need to track cross-layer dependencies (clip, mask, filter) in WGSL — a significant new shader surface with its own bug class.

The shared compositor with backend-internal optimizations (§ Backend Integration) captures 90% of the per-backend benefit with 33% of the code.

C. Full retained-mode rendering

Approach: Convert the entire render pipeline to retained mode — every element is a persistent GPU/CPU object that is updated incrementally. No explicit compositor or layer concept; the rendering backend maintains a scene graph internally and updates it when elements change.

Why it's too much for v1:

  1. Retained mode requires every backend to maintain internal scene graph state, which is a fundamental architectural change to RendererInterface (currently stateless per frame).
  2. Skia has limited retained-mode support (SkPicture is record-and-replay, not a mutable scene graph). TinySkia has none. Only Geode could reasonably implement retained mode.
  3. The compositor approach is a natural stepping stone toward retained mode: layers are persistent objects, and the composition tree is a simple scene graph. If retained mode proves necessary, the compositor can evolve into it. Starting with retained mode would be a bet-the-project rewrite.

D. Compositor at RendererDriver level (not ECS-aware)

Approach: Intercept RendererInterface calls from RendererDriver and cache them per layer, replaying cached call sequences for clean layers.

Pros: No new ECS components. Works with the existing traversal.

Cons: Cannot skip RendererDriver traversal for clean layers — the driver must still walk the entire render tree to produce the call sequence, even if the compositor discards most of it. The traversal itself is O(N) and non-trivial (pattern/mask/marker sub-traversals). The ECS-aware approach skips both traversal and rasterization for clean layers.

This is essentially the RendererRecorder (Phase 3 of 0003-renderer_interface_design) repurposed as a cache. It is a reasonable v1.5 fallback if the ECS-level approach proves too complex, but it leaves performance on the table.

Reversibility

The compositor is designed to be fully removable without changing the core rendering pipeline or ECS component model. Rollback is layered — each phase's feature gate is an independent runtime field on CompositorConfig, so a regression in (say) complexity bucketing can be disabled per-session without rebuilding, and without losing mandatory or interaction promotion.

Runtime feature gates (fields on CompositorConfig, passed to CompositorController's constructor; default-constructed config enables everything):

Field Gates Rollback effect
autoPromoteInteractions Interaction hints (Phase 2) Editor falls back to the explicit promoteEntity escape hatch. Mandatory hints still active.
autoPromoteAnimations Animation hints (subset of Phase 2) Animations re-render the whole document per tick; selection/drag compositing unaffected.
complexityBucketing Complexity bucketer (Phase 2.5) Skip the load-time partition; compositor behaves as in Phase 2. Click-to-first-drag latency returns to worst case, but correctness unaffected.

Why runtime, not compile-time:

  • Zero ABI fragmentation — library consumers link one compositor and toggle features per-instance. No #define matrix to manage.
  • Per-session rollback — a hotfix flips a config field from a user preference or environment variable without a rebuild.
  • Testability — the CI matrix instantiates controllers with different configs in a single binary rather than recompiling for each combination.

The primary kill-switch ("don't use the compositor at all") is not a feature gate — it's a linkage decision: a consumer that doesn't want compositing simply doesn't depend on //donner/svg/compositor. The editor's --experimental flag controls whether the compositor is constructed; when the flag is off, no CompositorController exists and all frames route through RendererDriver::render().

Tests exercise each gate individually (controller constructed with that field flipped) and in combination. Dual-path pixel-identity holds regardless of which gates are on.

Lazy ECS attachment: The compositor adds CompositorHintComponent and ComputedLayerAssignmentComponent to hinted entities. Both are attached lazily (only when a hint is active) and cleared when the last hint drops. No components are modified globally — unhinted entities are untouched. Removing the compositor feature deletes the donner/svg/compositor/ package and both component definitions; no other ECS components or systems change.

No RendererInterface changes in v1: The v1 tier-1 adapter adds AlphaType to RendererBitmap (a non-breaking struct field addition with a default value). The RendererInterface virtual table is unchanged. Reverting the compositor does not require changing any backend.

Test coverage invariant: The dual-path debug assertion (composited output == full-render output, extended in Phase 2 to cover animation-driven promotion) means the compositor can never silently produce wrong pixels — any regression is caught immediately and the fix is always "flip the relevant `CompositorConfig` field off" until the root cause is found — no rebuild required.

References

Next Steps

  1. Resolve open questions 8, 10, 11, 12 before Phase 2 code review. (OQ 9 and 13 are resolved above; the remaining four are budget / policy questions that don't block ScopedCompositorHint / InteractionHint API shape, but want concrete numbers before code lands.)
  2. Implement v1 (Phase 1) — compositor skeleton, hint-cascade plumbing (CompositorHintComponent, ComputedLayerAssignmentComponent, ScopedCompositorHint, minimal LayerResolver), dual-path debug assertion, translation-only drag fast path via explicit promotion escape hatch. Target: separate PR off main, linked from this design doc.
  3. Implement Phase 2 — interaction and animation hints, editor migrates to ScopedCompositorHint, new named tests (animation_isolation_test, click_to_first_drag_update_benchmark, interaction_hint_no_allocation_test).
  4. Implement Phase 2.5ComplexityBucketer at load. Independent CompositorConfig::complexityBucketing runtime gate. Measure load-time overhead against Goal 8.
  5. Implement Geode createOffscreenInstance() — prerequisite for Geode participation in compositor tests. Can be done in parallel with v1 (separate PR).
  6. Root-cause the premultiply roundtrip in existing takeSnapshot()drawImage() paths — this is a pre-existing correctness bug independent of the compositor. Fix alongside the tier-1 AlphaType adapter work.