The in-house renderer for BubblyDoo.
🚧
This page is still a work in progress. Please check back later for updates.
The BubblyDoo Renderer is the rendering engine that powers BubblyDoo.
It's quite unique in a few ways, and it has gone through a lot of iterations over the years, and it's one of the most interesting projects I've worked on for BubblyDoo.
It a React-based renderer, built around SVGs and
.
One of the strengths of BubblyDoo's product development is that
It is integrated with a custom Photoshop plugin that allows you to use layer selection and coloring inside of Photoshop.
It supports async rendering of HTML layers, and as such it does double buffering.
There were a lot of challenges, both from technical and a process perspective.
From a technical perspective, here's a small summary:
- async rendering
- done tracking
- live updates without re-rendering
- coloring using LUTs (in WebGL)
- integration with the in-browser editors
- partial deferring
- compression of images
- compatibility between Photoshop features and SVGs
History
First, this renderer was purely built in vanilla Javascript, without any frameworks.
This worked, but it was primarily based on full rerendering on every update, and partial updating was added as an afterthought.
Simple updates were possible, but big changes to the DOM tree couldn't be handled, which made the library inflexible.
I therefore decided to switch to React as the main technology behind the renderer. React makes it easy to do updates to the tree,
avoiding unnecessary DOM updates.
For SVG images, we parse the SVG into a React tree, and then modify the tree. That means that every image element in a personalized image on our website is represented by a React component, probably wrapped into multiple other React components. Although this causes thousands of React components to render,
React takes care of it without a problem, and the reduced complexity of the code is worth the React overhead.
React enabled us to do multiple things: deferring image loading, partial rendering to canvas, WebGL transformations, updating rendering between sRGB and P3 screens, etc.
A big challenge was keeping track of when layers were done rendering. For example, we need to make sure all images are loaded before we show
the preview to the customer, or before we're able to "capture" the image as a PDF. We preferably need to do this on a partial tree, because
we want to render some parts of the tree to a canvas. And we also need to be able to reset parts of the tree on update, e.g. when there are new
props and we need to rerender one layer of the tree.
This is why I wrote
, allowing us to wait for React components to be "done".
For async operations on the image, double buffering was essential, as it allowed us to do async operations on the images
without creating a flash on the screen when the content was updating, and we avoid tearing between multiple components.
To get around the feColorMatrix bug (described below), and to get around the low fidelity of feColorMatrix
(after all, the whole color transformation is defined by 20 numbers), we tried using 3D LUTs.
We wrote a script in WebAssembly (through AssemblyScript) to do color transformations in real time.
But even in web workers, the latency was too high.
An additional problem of LUTs was that their file size is quite high. For this we built and on-the-fly LUT compressor in Cloudflare Workers.
But in the end, the latency was too high, so we only enabled LUTs on macOS Chromium and to render the final PDFs we send for printing.
We didn't realize that there was a better solution: using WebGL. So after shelving the problem for a year and using a convoluted workaround
for P3 users, I learned about WebGL shaders and just implemented it in shader language.
Performance
There's a lot of consideration to performance in this library.
The main consideration is that the full personalizable SVG image is large to download and heavy to process.
Compression of images
We compress the individual images embedded in the SVG as PNG, WEBP or AVIF, and in multiple resolutions. We download them based on browser capabilities.
Partial deferring
The personalization image that is loaded on our website only shows the layers that are currently visible. We do this by running React on the server, and replacing every non-visible layer with a url reference. Once the image has rendered, the urls are resolved in the background.
Static preview
If you go to our website, a random seed (a number from 0-10) will be generated. Based on that seed, we randomize the personalization. Based on that personalization, we generate an image of the book cover on the server and cache it. We use that image in SSR and show it to the client as fast as possible. Meanwhile, we download the SVG and render it. When that's done, we switch the static preview to the interactive SVG.
Compression of LUTs
We generate large LUTs to transform the colors, because they have the highest resolution. But they also take a while to download. We wrote a Cloudflare Worker that can compress LUTs on the fly by applying the large LUT to a smaller, neutral LUT. In the browser, we can download LUTs of 4x4x4 while for the PDF generation we download larger LUTs.
Is this the best approach?
The big question will be: was this a better solution than writing a renderer that just renders everything to a canvas?
The enormous advantage of the DOM approach is that we can add custom components as easily as writing a React component.
It is quite ergonomical to work with, and even async operations (like downloading parts of the rendered image on demand), just work.
We can also easily wrap each React component, and interact with it (this is how our editor works).
The disadvantage is that cross-browser support is sometimes difficult, and that the DOM can be slow and difficult to work with.
The problem of feColorMatrix on P3 screens in Chromium
Color rendering in the BubblyDoo renderer heavily relies on feColorMatrix.
However, in Chromium browsers, feColorMatrix has
on non-sRGB screens:
the RGB values that Chrome uses to multiply with the color matrix are in the color space of the display, not in sRGB.
This created strange situations where colors were off depending on the screen you were using.
After many attempted workarounds I found out that adding an HDR image somewhere on the page forces Chromium to do a different kind of rendering,
and with it, the filter suddenly worked.
This was not ideal because it makes the screen flicker, so on broken screens,
we now circumvent Blink completely and calculate the feColorMatrix effect ourselves using WebGL.
I ended up writing a
which was merged,
but I'm still awaiting an upstream fix in Chromium Blink's engine.
🔧 React🔧 WebGL🔧 Cloudflare Workers🔧 Vue.js🔧 AWS Lambda