|
|
Donner 0.5.1
Embeddable browser-grade SVG2 engine
|
Status: Draft Author: Claude Opus 4.6 Created: 2026-04-13
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 (StyleComponent → ComputedStyleComponent) — 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.
| 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. |
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:
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.
Three arguments:
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.
| 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. |
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.
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).
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.
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).
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 |
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.
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:
Production editor code SHOULD NOT use promoteEntity directly. Reviewers should treat new callers skeptically.
During a drag, after the interaction hint has promoted entity E:
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).
(The v0 "elements the user is not interacting with" rule is removed — it contradicted complexity-bucket pre-promotion.)
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.
Per-subtree cost is computed in a single post-order walk:
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 bucket count: K = target_layer_count - reserved_slots, where
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.
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.
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.
After createComputedComponents(), dirty entities still have their DirtyFlagsComponent attached (cleared at end of frame). The DamageTracker consumes these:
For each entity with DirtyFlagsComponent:
Compute screen-space damage. The damage region is the union of:
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.
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.
If RenderTreeState::needsFullRebuild is set (tree structure changed), the compositor:
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.
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).
Layers are sorted by the minimum drawOrder of their member entities. This preserves SVG paint order.
For each layer in draw order:
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-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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
Claim: conservative fallback.
Any tree mutation that changes z-order (insertion, deletion, reordering) sets RenderTreeState::needsFullRebuild. The compositor responds by:
This is correct because it degrades to today's behavior. It is also rare during interactive editing — drag operations change transforms, not tree structure.
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:
No compositor-specific logic needed. The correctness argument is: the compositor never uses stale computed styles because it only rasterizes after createComputedComponents() completes.
Claim: exact for non-overlapping layers. Conservative fallback for overlapping promoted layers.
When two promoted layers overlap in screen space:
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.
| 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 |
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.
Problem: The layer bitmap must be large enough to contain the promoted element's full visual extent, including:
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).
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.
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.
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.
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.
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.
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).
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.
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.
In addition to the base golden tests listed above, add these SpecBot-recommended tests:
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.
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).
Tier 2 (v2, per-backend fast path): Add a LayerHandle abstraction to RendererInterface that bypasses RendererBitmap entirely:
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'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) → ImageResource → drawImage() (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 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).
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.
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:
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.
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.
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:
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.
A new test target (`compositor_fuzz_tests`) generates random SVG scenes with:
For each scene:
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.
Dedicated test cases for the correctness edge cases enumerated above:
Each test renders both paths and asserts pixel identity.
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:
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.
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):
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).
CompositorLayer stores an Entity handle. The ECS registry may invalidate entities (e.g., via removeEntity()). The compositor must validate entity existence before accessing components:
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.
The compositor does not introduce new trust boundaries beyond those already enforced by the parser and renderer. Specifically:
Goal: Fluid drag of ONE promoted shape with correctness guarantees.
Prerequisites (must exist before implementation begins):
Implementation steps (correctness-first order):
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.
Prerequisites: Phase 1 complete. The hint cascade plumbing exists and carries Explicit + Mandatory hints.
Prerequisites: Phase 2 complete. Independent rollback flag.
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.
Approach: Each backend implements its own compositor. Skia uses SkPicture + SkSurface tile cache. Geode uses GPU render-to-texture
Why it doesn't work well:
The shared compositor with backend-internal optimizations (§ Backend Integration) captures 90% of the per-backend benefit with 33% of the code.
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:
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.
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:
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.