This is the second post about the recent work on the layer-based SVG engine (LBSE) in WebKit. In the first part I described conditional layer creation, where a renderer is given a RenderLayer only when it truly needs one. That made the common case fast and lean, but it also left something broken behind it: if an element is composited while its siblings are not, it no longer paints in its correct document-order slot.
This post is about how we got that paint order back. We will start with the contract SVG has to honour and why compositing quietly breaks it, then follow one small test case through two very different fixes. The first, sandwich promotion, worked but had to be thrown away. The second, paint order segments, is the one that landed upstream.
The paint order contract
SVG follows a strict rule: content paints in document order. The first child paints first, the last child paints last
and ends up on top, and nothing an element does to itself is allowed to change that order relative to its siblings (except
z-index - which is non-standard so far). It could hardly be simpler.
Compositing does not play by that rule. When an element is composited it gets its own GraphicsLayer, which the compositor can move and animate independently, and the compositor then stacks those layers one over the other. A non-composited element, by contrast, gets no layer of its own. It paints into the backing store of the nearest enclosing layer that has one, along with every other plain sibling. As long as everything in a container is composited, or nothing is, this all works out fine. The trouble starts once the two are mixed, because the plain children then all paint into one backing store at one depth, and any of them that belongs after a composited sibling cannot get there.
To make that concrete, here is the small test case I will use for the rest of the post. I have trimmed it down to the
parts that matter for paint order. A group holds six children in document order. Two of them, C0 and C1, run an
accelerated CSS transform animation, so the compositor gives each its own GraphicsLayer. The other four, n0, n1,
n2 and n3, are plain static shapes with nothing on them that needs a layer, so under conditional layer creation they
get none at all.
<style>
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 4s linear infinite;
will-change: transform; /* promote to its own compositing layer */
transform-box: fill-box; /* rotate around the rect's own box */
transform-origin: center;
}
</style>
<g>
<rect id="n0" x="0" y="0" width="40" height="40" fill="#2b6cb0"/> <!-- plain -->
<rect id="C0" x="25" y="0" width="40" height="40" fill="#319795" class="spin"/> <!-- composited -->
<rect id="n1" x="50" y="0" width="40" height="40" fill="#38a169"/> <!-- plain -->
<rect id="n2" x="75" y="0" width="40" height="40" fill="#d69e2e"/> <!-- plain -->
<rect id="C1" x="100" y="0" width="40" height="40" fill="#dd6b20" class="spin"/> <!-- composited -->
<rect id="n3" x="125" y="0" width="40" height="40" fill="#c53030"/> <!-- plain -->
</g>
The same markup rendered, frozen half a second into the animation so that C0 and C1 sit at 45 degrees. The fills run from blue at the back to red at the front, following document order, so the overlaps show each child covering the one before it. The two rotated squares are the composited ones, and turning them does not move either out of its slot.
The contract says these six children paint in exactly this order, with n0 at the back and n3 on top, no matter
which of them happen to be composited.
With conditional layer creation enabled, C0 and C1 each get their own GraphicsLayer. The group itself has none, so
n0, n1, n2 and n3 paint into the backing store of the nearest enclosing layer that does have one, which is
typically the document’s root layer. The compositor could place that enclosing layer either in front of both C0 and C1
or behind both of them, but it cannot slide it in between them. So n1 and n2, which belong in the middle, and n3,
which belongs on top of C1, are all forced either above C1 or below C0, and either way the picture is wrong.
I started calling this the sandwich, because the two composited siblings act like the slices of bread and the plain content caught between them is the filling. The analogy is easier to see if we flip the paint order axis, as in the following diagram:
Attempt one: “Sandwich promotion”
The first fix I reached for looked like the natural one. If plain content sits between or after composited siblings, the obvious way out is to make it composited as well. Everything ahead of the first composited sibling can stay as it is, since it paints into the enclosing layer’s backing store at the back, but from that point on every child needs a RenderLayer of its own, and eventually a GraphicsLayer, before the compositor can put them all back in the right order.
Making that happen takes two steps, and each is decided at a different time. First, at style time, whenever a child is
intrinsically layered (e.g. will-change: transform, 3D transforms, opacity, etc.), every sibling that follows it is
pre-emptively given a RenderLayer, before anyone knows whether it will be composited. That step is unavoidable,
because the compositor can only promote a child to composited if it already owns a RenderLayer. In our test case that
catches everything after C0, meaning n1, n2, C1 and n3. Then, in the later compositing pass, every child after
the first composited sibling is promoted to composited as well, and that is what finally restores document order. We
scanned for these runs both before and after recursing into the tree, so that a sibling which only became composited
during recursion was caught too. Promoting the sandwiched children like this is what I called “sandwich promotion”.
It worked, and all the tests passed.
The problem is how far that first step reaches. The moment one child in a list is intrinsically layered, every sibling
that follows it is handed a RenderLayer, and that happens at style time, before any compositing decision exists. A
single opacity on an early child is enough to give a RenderLayer to every shape after it, even when nothing in that
list ever ends up composited. We had just spent half a year carefully avoiding a RenderLayer for every shape, and here
was a scheme that quietly forced a pile of them back into existence, fighting against the very goal of the project.
And it does not stop at the extra layers. Every insert, removal or style change inside a container schedules a rescan of that container’s children, and the marking pass then creates or destroys RenderLayers wherever the answer has flipped. The compositing pass walks the children twice more on top of that, once before recursing into the tree and once after, so that a sibling which only became composited during the recursion is caught as well. None of that work exists in a tree that has no composited content at all, and all of it has to run again every time anything moves.
After some fruitful discussions with Apple’s compositing experts, it became clear that these two problems made the whole approach unsustainable. So I closed the pull request and threw it away. That sent me back to the drawing board, looking for a sane design that does not suffer from potential performance cliffs.
Attempt two: Paint order segments
The second attempt flipped the strategy on its head. Instead of dragging the plain content up into the composited world, it keeps that content plain and flat and gives the compositor one extra overlay GraphicsLayer for every run of it that falls after a composited sibling.
The trick is to cut the flat list of children into pieces at every composited sibling, and I call these pieces paint order segments. Each segment is simply a run of children that gets painted by one GraphicsLayer, at the correct depth. Everything before the first composited sibling keeps painting into the enclosing layer’s backing store, exactly as before. Each composited sibling keeps its own GraphicsLayer. And every run of plain children that falls after a composited sibling gets an overlay GraphicsLayer of its own, named something like (svg segment 1), which paints just that slice of the list and nothing else.
Let’s walk our test case through it. n0 keeps painting into the enclosing layer’s backing store. C0 is composited
and paints into its own GraphicsLayer on top of that. n1 and n2 fall after C0, so they go into a first overlay,
(svg segment 1), sitting just above it. C1 is composited and paints above that overlay. Finally n3 falls after
C1, so it goes into a second overlay, (svg segment 2), on top of everything.
Each overlay is nothing more than another GraphicsLayer in the compositor’s child list, painting one slice of the flat list at the depth where that slice belongs. The work landed upstream as 315674@main.
What I like about this design is what it costs when nothing needs it. The segments are recomputed from the state after compositing has been decided, not from the style, so only a child that really is composited ever splits the list. A container whose children are merely intrinsically layered, without any of them being composited, produces no segments and no overlays, and none of its siblings are handed a RenderLayer they will never use. That is exactly the trap the first attempt fell into, and it is gone. The common case stays cheap, and we only pay for the extra complexity in the rare spot that genuinely needs it.
Getting this segment logic right is what forced the decision on which renderers need a RenderLayer at all. The compositor works out the current transformation matrix of a composited element from the layers above it, so every transform on the way down has to be visible in that walk. A leaf shape carrying nothing but a 2D transform can therefore stay layer-free, because it has no descendants that could ever be composited and LBSE simply applies the transform while painting it. A container is a different matter. The moment it carries a transform it needs a RenderLayer of its own, even for a plain 2D one, or a composited descendant somewhere below it would end up with the wrong matrix. So in LBSE every transformed container is layered, and only the leaves get to escape.
There was a small amount of fallout to handle along the way. Now that layer creation is conditional, a change to the transform attribute can flip an element between needing a layer and not, whenever it crosses between identity and non-identity. We route those changes through a style recalc so the engine re-evaluates the layer cleanly, broadened that to cover all SVG containers, and added a good batch of new tests for all the tricky mixed cases.
Where we are now
After six months of this work, LBSE no longer drowns the engine in layers. Ordinary SVG content, meaning the shapes and untransformed groups that make up the bulk of every drawing, now lives entirely without a RenderLayer, and layers appear only where something actually needs one, for filters, clips, masks, transformed containers and genuine compositing. Paint order stays correct in every mixed case, and it does so without any of the performance cliffs that my first attempt would have baked in.
Taken together, this was the most invasive change the engine has ever seen, and it touched almost every corner of the SVG rendering code. It would not have happened without Rob Buis, who carefully reviewed every single patch, or without Simon Fraser, who went through most of the gnarly compositing pieces and kept asking the hard questions that pushed me away from the sandwich and toward segments. Thanks also to Said, Karl and Ahmad from Apple for the insightful discussions and input on how to move LBSE forward. And it would not have happened at all without Igalia and this one-off sponsoring, for which I am grateful.
There is more to come, too. The direction is to go after the features that still force a layer, so that filters, clips and masks can paint without a RenderLayer as well, and every SVG painting feature ends up layer-free by default wherever that can be done. A few things will always insist on a layer, 3D transforms and perspective among them, because there is simply no way around them. But the list of features that quietly pull a layer into existence should keep getting shorter.
Concretely, the next step is to remove the layers that clipping still needs, and then to focus on MotionMark/Suits until LBSE reaches performance parity in that test case. After my holidays, I will write a dedicated blog post about the performance optimizations.
Thanks for reading to the end, and sorry for the level of technical detail that was necessary to explain the new paint order segments concept :-)