Skia's raster blur runs at full resolution, so each bar blurred width x (bar + 6 sigma) pixels every frame: about 80 ms per frame for the two bars of a chat at the default radius, which cut scrolling to 5-9 fps on the software renderer. Scaling the layer cannot help, because a layer's filter is evaluated in device space and the scale is applied to the sigma too. The bar is now drawn into an offscreen surface up to 8x narrower and blurred there, which measured 5-6x cheaper and within 0.25/255 of the original. Blurring a copy means the bar no longer follows the content on its own, so the scroll containers bump a version on the app bar handler and the bar depends on it. Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
6.2 KiB
Plan: make the desktop bars blur lightweight
Context
Settings > Appearance > Blur blurs the app bars: blurredBackgroundModifier puts a BlurEffect on the bar's layer
and draws a copy of the scrolled content inside it, so the blur is recomputed every frame. On desktop this made
scrolling visibly laggy at the default radius of 50.
Measurements
Measured on the software renderer (SKIKO_RENDER_API=SOFTWARE), 1376x768 window, density 1, scrolling a chat of 70
messages. Frames were counted by capturing the window repeatedly and counting distinct images.
| blur off | blur 50 | |
|---|---|---|
| visible frames per second while scrolling | 14.5 | 5.2 - 8.7 |
A standalone harness that replays the same drawing through Skiko's RenderNode puts the cost of one bar at 29 ms
(radius 30), 42 ms (radius 50) and 71 ms (radius 100). Two bars are drawn in a chat, so at the default radius the
bars alone cost about 80 ms per frame.
Cause
Skia's raster blur runs at full resolution: Raster8888BlurAlgorithm only rescales its input above sigma 135
(SkBlurEngine.cpp), and every radius the slider offers is below that. Each bar therefore blurs
width x (bar + 6 sigma) pixels per frame, which at radius 100 is the full window width by 404 rows.
The obvious remedy, blurring a downscaled copy the way Skia's own GPU path does, cannot be expressed by scaling the layer: a layer's image filter is evaluated in device space, so any scale on the layer or the canvas is applied to the sigma as well. Narrowing a bar eightfold and blurring it with an eighth of the sigma measured 68.5 ms against 68.4 ms for the untouched blur, and looked wrong, because the reduced sigma ended up applied at full resolution. This is why the blur appeared weak enough to leave text under the bar readable.
Fix
Draw the bar into an offscreen Skia surface that is up to 8x narrower, blur it there, where the surface's own
resolution is the device resolution and the sigma is not scaled back up, then stretch the finished blur across the
bar. Only the width is reduced: a bar is AppBarHeight tall, and taking rows away from it visibly weakens the blur,
which the harness confirmed and which was visible in the app.
drawBarsBlurred is an expect/actual because it needs Skia surfaces directly. Android is untouched: it blurs
through a RenderEffect on the layer, where the GPU blur rescales internally, so it never had this cost.
Blurring a copy has one consequence for when the bars draw. Previously the bar recorded drawLayer(graphicsLayer), a
live reference that replays whatever the content layer holds at playback, so the bar did not have to be redrawn when
the content scrolled. Reading the content into a surface fixes it at the moment the bar draws instead, so the bar has
to be redrawn whenever that copy is re-recorded, which AppBarHandler.contentVersion provides: copyViewToAppBar
bumps it after each recording and the bar reads it. Without it the blur inside a chat lagged behind the content,
while the chat list looked correct because its bar happened to be invalidated by other state.
The surfaces are kept per size rather than one pair overall, because the desktop window's panes have different widths and their bars are drawn in the same frame, which would otherwise reallocate both surfaces for every bar.
How far the copy is reduced follows from the blur itself. The width is divided until the blur's own sigma would fall below 1.5, past which the narrowed copy is barely blurred and stretching it back out shows the steps of the narrowing rather than a blur. The rows are halved once, with the vertical sigma halved to match. The rule has to be written in terms of the reduced sigma rather than the radius: stopping as soon as the reduced sigma reached 4, as a first version did, left the small radiuses barely narrowed at all and made radius 10 cost more than radius 50.
The blur is the surface's own layer paint rather than a copy between two surfaces, which is both one allocation fewer and, measured with the surfaces reused as the app reuses them, 3-11% quicker.
Per bar, against the unmodified blur, with surfaces reused:
| radius | before | after | difference from the original |
|---|---|---|---|
| 10 | 17.9 ms | 2.7 ms | 0.41 / 255 |
| 50 | 40.9 ms | 3.0 ms | 0.23 / 255 |
| 100 | 72.7 ms | 5.0 ms | 0.27 / 255 |
The difference column is the root mean square difference over the bar, before the bar's own tint is applied, so what reaches the screen is smaller still. Redrawing the bar and stretching the blur back over it cost 0.5 ms of those figures whatever the radius, so that is the floor this approach can reach.
Keeping the bars in step with the content
A layer's own filter follows the content it is attached to for free. Blurring a copy does not: the copy is taken when
the bar draws, so the bar has to be redrawn whenever the content it copies has moved. AppBarHandler.contentVersion
carries that, and the scroll containers bump it from a collector on the state they actually scroll.
Two details are load-bearing. The bump must not happen while drawing: an earlier version bumped it inside
copyViewToAppBar, and because Compose Desktop redraws the whole scene per frame, the content's draw invalidated the
bar, which requested another frame, which drew the content again. Measured idle, with no interaction: 96% of a core
against 0% with the blur off and 0% for the unmodified app. And the version has to come from the state the container
actually scrolls, not from the handler's own: a chat supplies its own LazyListState, so watching the handler's left
the chat's blur stale while the chat list looked correct.
Rejected
Reusing the blurred copy while the content is unchanged was measured and dropped: it saves nothing while scrolling, which is the case that lags, and the chat wallpaper is recorded into its own layer without touching the content version, so a cached blur could outlast a wallpaper change.
Blurring the bars of both panes in one pass does not apply: only DefaultAppBar is blurred, one per screen, and the
two on a desktop window belong to panes of different widths side by side rather than stacked.
Verification
Built as an AppImage and checked by eye across the whole slider range at bar alpha 0.5, where the blur is most exposed, on top of the chat list and inside a chat.