Nuts and bolts of Canvas2D - globalCompositeOperation and shadows.
In recent months I’ve been privileged to work on the transition from Cairo to Skia for 2D graphics rendering in WPE and GTK WebKit ports. Big reworks like this are a great opportunity to explore all kinds of graphics-related APIs. One of the broader APIs in this area is the CanvasRenderingContext2D API from HTML Canvas. It’s a fairly straightforward yet extensive API allowing one to perform all kinds of drawing operations on the canvas. The comprehensiveness, however, comes at the expense of some complex situations the web engine needs to handle under the hood. One such situation was the issue I was working on recently regarding broken test cases involving drawing shadows when using Skia in WebKit. What makes it complex is that some problems are still visible due to multiple web engine layers being involved, but despite that I was eventually able to address the broken test cases.
In the next few sections I’m going to introduce the parts of the API that are involved in the problems while in the sections closer to the end I will gradually showcase the problems and explore potential paths toward fixing the entire situation.
Drawing on Canvas2D with globalCompositeOperation
#
The Canvas2D API offers multiple methods for drawing various primitives such as rectangles, arcs, text etc. On top of that, it allows one to control compositing and clipping
using the globalCompositeOperation
property. The idea is very simple - the user of an API can change the property using one of the predefined
compositing operations and immediately after that, all new drawing operations will behave according to the rules the particular compositing operation specifies:
canvas2DContext.fillRect(...); // Draws rect on top of existing content (default).
canvas2DContext.globalCompositeOperation = 'destination-atop';
canvas2DContext.fillRect(...); // Draws rect according to 'destination-atop'.
There are many compositing operations, but I’ll be focusing mostly on the ones having source
and destination
in their names.
The source
and destination
terms refer to the new content to be drawn and the existing (already-drawn) content respectively.
The images below present some examples of compositing operations in action:
Drawing on Canvas2D with shadows #
When drawing primitives using the Canvas2D API one can use shadow*
properties
to enable drawing of shadows along with any content that is being drawn. The usage is very simple - one has to alter at least one property such as e.g. shadowOffsetX
to make the shadow visible:
canvas2DContext.shadowColor = "#0f0";
canvas2DContext.shadowOffsetX = 10;
// From now on, any draw call will have a green shadow attached.
the above combined with simple code to draw a circle produces a following effect:
Shadows meet globalCompositeOperation
#
Things are getting interesting once one starts thinking about how globalCompositeOperation
may affect the way shadows are drawn. When I thought about it for the first time, I imagined at least 3 possibilities:
- Shadow and shadow origin are both treated as one entity (shadow always below the origin) and thus are drawn together.
- Shadow and shadow origin are combined and then drawn as a one entity.
- Shadow and shadow origin are drawn separately - shadow first, then the content.
When I confronted the above with the drawing model and shadows specification, it turned out the last guess was the correct one. The specification basically says that the shadow should be computed first, then composited within the clipping region over the current canvas content, and finally, the shadow origin should be composited within the clipping region over the current canvas content (the original canvas content combined with shadow).
The above can be confirmed visually using few examples (generated using chromium browser v126.0.6478.126):
- The
source-over
operation shows the drawing order - destination first, shadow second, and shadow origin third. - The
destination-over
operation shows the reversed drawing order - destination first, shadow second (below destination), and shadow origin third (below destination and shadow). - The
source-atop
operation is more tricky as it behaves likesource-over
but with clipping to the destination content - therefore, destination is drawn first, then clipping is set to destination, then the shadow is drawn, and finally the shadow origin is drawn. - The
destination-atop
operation is even more tricky as it behaves likedestination-over
yet with the clipping region always being different. That difference can be seen on the image below that presents intermediate states of canvas after each drawing step:
- The initial state shows a canvas after drawing the destination on it.
- The after drawing shadow state, shows a shadow drawn below the destination. In this case, the clipping is set to new content (shadow), and hence the part of destination that is not “atop” shadow is being clipped out.
- The after drawing shadow origin state, shows the final state after drawing the shadow origin below the previous canvas content (new destination) that is at this point “a shadow combined with destination”. Similarly as in the previous step, the clipping is set to the new content (shadow origin), and hence any part of new destination that is not “atop” the shadow origin is being clipped out.
Discrepancies between browser engines #
Whenever one realizes the drawing of shadows with globalCompositeOperation
in general may be tricky, then one must also consider that when it comes to particular browser engines, the things are even more tricky as virtually no graphics library
provides an API that matches the Canvas2D API 1-to-1. This means that depending on the graphics library used, the browser engine must implement more or less integration parts
here and there. For example, one can imagine that some graphics library may not have native support for shadows - that would mean the browser engine has to prepare shadows itself by e.g. drawing shadow origin (no matter how complex) on extra
surface, changing color, blurring etc. so that it can be used as a whole once prepared.
Having said the above, one would expect that all the above aspects should be tested and implemented really well. After all, whenever the subject matter becomes complicated, extra care is required. It turns out, however, this is not necessarily the
case when it comes to globalCompositeOperation
and shadows. As for the testing part, there are very few tests
(2d.shadow.composite*
) in WPT (Web Platform Tests) covering the use cases described above. It’s also not much better for internal web engine test suites. As for implementations, there’s a substantial amount of
discrepancy.
Simple examples #
To show exactly what’s the situation, the examples from section Shadows meet globalCompositeOperation
can be used again. This time using browsers representing different web engines:
- Chromium 126.0.6478.126
- Firefox 128.0
- Gnome Web (Epiphany) 45.0 (WebKit/Cairo)
- WPE MiniBrowser build from WebKit@
098c58dd13bf40fc81971361162e21d05cb1f74a
(WebKit/Skia) - Safari 17.1 (WebKit/Core Graphics)
- Servo release from 2024/07/04
- Ladybird build from 2024/06/29
First of all, it’s evident that experimental browsers such as servo and ladybird are falling behind the competition - servo doesn’t seem to support shadows at all, while ladybird doesn’t support anything other than drawing a rect filled with color.
Second, the non-experimental browsers are pretty stable in terms of covering most of the combinations presented above.
Finally, the most tricky combination above seems to be the one including destination-atop
- in that case almost every mainstream browser renders different results:
- Chromium is the only one rendering correctly.
- Firefox and Epiphany are pretty close, but both are suffering from a similar glitch where the red part is covered by the part of destination that should be clipped out already.
- WPE MiniBrowser and Safari are both rendering in correct order, but the clipping is wrong.
More sophisticated examples #
Until now, the discrepancies don’t seem to be very dramatic, and hence it’s time to present more sophisticated examples that are an extended version of the test case from the WebKit source tree:
- Chromium 126.0.6478.126
- Firefox 128.0
- Gnome Web (Epiphany) 45.0 (WebKit/Cairo)
- WPE MiniBrowser build from WebKit@
098c58dd13bf40fc81971361162e21d05cb1f74a
(WebKit/Skia)
- Safari 17.1 (WebKit/Core Graphics)
- Servo release from 2024/07/04
- Ladybird build from 2024/06/29
Other than destination-out
, xor
, and a few simple operations presented before, all the operations presented above pose serious problems to the majority of browsers. The only browser that is correct in all the cases
(to the best of my understanding) is Chromium that is using rendering engine called blink which in turn uses the Skia library. One may wonder if perhaps it’s Skia that’s responsible for the Chromium success,
but given the above results where e.g. WPE MiniBrowser uses Skia as well, it’s evident that the problems lay above the particular graphics library.
Looking at the operations and browsers that render incorrectly, it’s clearly visible that even small problems - with either ordering of draw calls or clipping - lead to spectacularly broken results. The pinnacle of misery is the source-out
operation that is the most variable one across browsers. One has to admit, however, that WPE MiniBrowser is slightly closer to being correct than others.
Towards unification #
Fixing the above problems is a long journey. After all, every single web engine has to be fixed in its own, specific way. If the specification would be a problem - it would be the obvious way to start. However, as mentioned in the section
Shadows meet globalCompositeOperation
, the specification, is pretty clear on how drawing, shadows, and globalCompositeOperation
come together. In such case, the next obvious place to
start improving things is a WPT test suite.
What makes WPT outstanding is that it is a de facto standard cross-browser test suite for testing the web platform stack. Thus the test suite is developed as an open collaboration effort by developers from around the globe and hence is very broad in terms of specification coverage. What’s also important, the test results are actively evaluated against the popular browser engines and published under wpt.fyi, therefore putting some pressure on web engine developers to fix the problems so that they keep up with competition.
Granted the above, extending WPE test suite by adding test cases to cover globalCompositeOperation
operations combined with shadows is the reasonable first step towards the unification of browser implementations. This can be done either by
directly contributing tests to WPT, or by creating an issue. Personally, I’ve decided to file an issue first (WPT#46544) and to add
tests once I have some time. I haven’t contributed to WPT yet, but I’m excited to work with it soon. Once I land my first pull request, I’ll start fixing WebKit and I won’t hesitate to post some updates on this blog.