One renderer: moving Brilliant's canvas to Rust and wgpu
Brilliant is a Flutter app, and for a long time the canvas was too. Every rectangle, every glyph, every gradient you saw was drawn by a Flutter CustomPainter walking the element tree on the UI thread. That painter served us well until it didn't, and the ceiling it hit was not a bug we could fix. It was the architecture. So we replaced it. Canvas content in Brilliant now renders through a native Rust engine built on wgpu. This post is about what that swap bought, and about the one optimization we were sure we needed and then measured away.
Why the painter had to go
A design tool is a rendering loop wearing a trench coat. Every drag, every nudge, every zoom tick is a full re-evaluation of a live graph, and the round trip from "the model changed" to "the pixels changed" has to fit inside a display frame or the whole thing feels like mud. The Flutter painter did this on the same thread that runs your gestures, your layout, and your UI. On small documents that is fine. On a 13,000-element canvas at a deep zoom, it is a slideshow.
We could have chased that with caching and tiling forever. Instead we moved the pixels to where the pixels belong: a GPU renderer written in Rust. Dart still owns everything that makes Brilliant Brilliant: the model, the transforms, hit-testing, snapping, design tokens, undo. The engine is a deliberately dumb, extremely fast renderer of primitives. Dart decides what; the engine decides how fast.
The important part, the part we want to be honest about: there is no Flutter fallback anymore. The legacy widget and tile pipeline is deleted, not disabled. There is no per-canvas opt-out, no "classic renderer" toggle hiding in settings. If the native library fails to load, or the engine faults at runtime, the canvas does not quietly fall back to a slower path that renders something slightly different. It shows an explicit failure screen: Retry if the library never loaded, Restart Rendering if the engine died at runtime. That is the whole contract. We found it clarifying to have exactly one renderer. A fallback is a second rendering path you have to keep pixel-honest forever, and the cheapest way to keep two renderers in agreement is to have one.
One renderer also means exports stop being a separate story. A PNG, a PDF, a copy-as-image: they come out of the same engine that painted your screen, so what you see is exactly what you export. There is no second rasterizer to drift a hairline or fatten a glyph on the way out.
Only what changed
The obvious way to feed a renderer is to hand it the whole scene every frame. The obvious way is also the slow way. Re-describing a 13K-element document sixty times a second is exactly the O(document) per-frame work we were trying to escape.
So the engine is fed changes, not snapshots. When the model attributes an edit to a small set of elements (you dragged one rectangle, you typed one character), only that work crosses to the engine, and everything you didn't touch costs nothing at all. The payoff shows up at the extremes: dragging a selection across a 13,000-element document costs roughly what it would on a ten-element one, and typing into a text element never triggers a document walk. The common interactions were designed to cost almost nothing to describe, so the frame budget gets spent on pixels instead of bookkeeping.
Content and chrome never disagree
Your toolbars, panels, and menus are still Flutter widgets. The canvas underneath them is Rust. The requirement that makes that split feel like one application is subtle: content and chrome must never disagree, even for a single frame. If the engine drew on its own schedule while Flutter painted the selection rectangle on Flutter's schedule, then during a fast drag your selection outline would visibly lag the thing it is selecting.
So the two are locked together. The canvas pixels, the selection and snap chrome on top of them, and the camera they were drawn under all land in the same frame, every frame. Grab a shape and throw it across the screen: the outline stays glued to it at full speed. The selection logic itself never left Dart. Only the drawing moved.
Retiring the painter without losing its judgement
Deleting a renderer is easy. Deleting a renderer without shipping a thousand tiny visual regressions is not. The painter had years of accumulated correctness baked into it, most of it undocumented, all of it load-bearing. How do you prove the new engine matches it when you are about to delete the thing you would compare against?
You freeze it first. Before the painter came out, we captured its final renders across a battery of canonical scenes covering everything it knew how to draw: shapes, radii, gradients, strokes, blends, flips, arcs. Those captures are the painter's last will and testament, and pixel parity against them gated the entire migration. The painter did not leave until the engine could stand in front of its life's work and match it.
From here on the rules change, and we like the new rules better. There is no painter left to produce a "correct" reference, so the engine is now its own source of truth for how a pixel looks, held to its own consistency: the same scene must render identically every time, everywhere it renders, screen and export alike.
The zoom cache we measured away
We want to tell one optimization story straight, because it went the opposite of how we expected.
Liquid glass in Brilliant refracts its backdrop, and the optics depend on the shape's silhouette evaluated against the camera. The received wisdom, and our own instinct, was that you cannot afford to evaluate that live every frame during a zoom gesture. You bake it into a zoom-keyed cache and reuse it. We built exactly that, in two versions.
Then we sat down to validate it properly, and the cache lost.
We keep a written rendering contract, a north star every engine decision gets held against, and one of its notes we had been quietly violating: glass optics are zoom-covariant. World distance times zoom equals screen distance, so anything represented in world space and evaluated against the live camera is exact at every zoom with zero per-zoom precompute. A zoom-keyed cache, by construction, has to either stretch a stale bake (blurry) or re-bake mid-gesture (janky). It trades away a rule we care about no matter which way it fails.
So we spiked it: live evaluation versus the cache, A/B, on real content. On a founder's actual 4,919-element capture at his real zoom range (0.02 through 53), the numbers came back like this:
leg gpu_content p50 p95 max
LIVE 0.10 ms 0.63 1.45
CACHED 0.58 ms 1.90 2.19Live evaluation was not a tolerable regression we would accept for sharper pixels. It was faster, roughly 5x faster at the median. The per-tick re-bake the cache performed during a zoom gesture cost more than the evaluation it was supposedly saving. And the artifacts the founder had reported, simple vector geometry crawling past a zoom threshold, pixelation at extreme zoom, were the cache's re-bake stutter and its stretched reuse. The optimization was the bug.
We deleted both cache versions. Glass now evaluates live, exact at every zoom. For genuinely pathological content, the kind no cache would have rescued either, the behavior is the one our rendering contract demands: a deterministic, visible reduction that settles back to exact the moment the pressure lifts, never a silent quality trade. The verdict is written down, with the measurement, because "we measured, the cache lost" is a better artifact than a clever cache nobody ever questioned.
What we got
The canvas is a Rust engine now. Dart still holds the model and the meaning; the engine holds the milliseconds. The engine only ever hears about what changed, the chrome never lags the content it decorates, exports are pixel-identical to the canvas, and the renderer we deleted lives on as the frozen reference that gated its own replacement. We got one renderer instead of two, which is one contract to keep honest instead of a permanent negotiation between them. And we got a habit we intend to keep: when an optimization feels obviously necessary, measure it against the thing it is supposed to beat, and be willing to find out it doesn't.
If the language Brilliant's agents use to build all of this is interesting to you, the Blueprint post is the other half of the story.