desktop: fix laggy bars blur by blurring a narrower offscreen copy (#7556)

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>
This commit is contained in:
Narasimha-sc
2026-09-26 12:42:36 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 9cd1c46606
commit ef66e5e520
7 changed files with 249 additions and 27 deletions
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.drawBarsBlurred
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(CurrentColors.value.colors.background)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
val barWidth = size.width
val barHeight = size.height
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
handler.contentVersion.value
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
if (onTop) {
clipRect(0f, 0f, barWidth, barHeight) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
}
}
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
val contentVersion: MutableState<Int> = mutableStateOf(0)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -0,0 +1,95 @@
package chat.simplex.common.platform
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.*
import kotlin.math.ceil
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
private const val MAX_BLUR_NARROWING = 8
private const val MIN_BLUR_WIDTH = 128
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
// back out shows the steps of the narrowing instead of a blur.
private const val MIN_NARROWED_SIGMA = 1.5f
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
private const val MIN_BLUR_HEIGHT = 24
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
// released once there are more than the window can show at once, allocates each pair once instead.
private const val MAX_BAR_SURFACES = 4
private class BarBlurSurface(width: Int, height: Int) {
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
fun close() = surface.close()
}
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
val cached = barBlurSurfaces.get()
val key = (width.toLong() shl 32) or height.toLong()
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
cached[key] = surface
while (cached.size > MAX_BAR_SURFACES) {
cached.remove(cached.keys.first())?.close()
}
return surface
}
private fun narrowing(sigma: Float, barWidth: Float): Int {
var scale = 1
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
return scale
}
private fun shortening(sigma: Float, barHeight: Float): Int =
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
val scale = narrowing(sigma, barWidth)
val rows = shortening(sigma, barHeight)
val width = ceil(barWidth / scale).toInt()
val height = ceil(barHeight / rows).toInt()
if (width <= 0 || height <= 0) return
val surface = surfaceFor(width, height).surface
surface.canvas.clear(Color.TRANSPARENT)
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
})
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
}
surface.canvas.restore()
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
drawIntoCanvas {
it.nativeCanvas.drawImageRect(
surface.makeImageSnapshot(),
Rect.makeWH(barWidth / scale, barHeight / rows),
Rect.makeWH(barWidth, barHeight),
SamplingMode.LINEAR,
null,
true
)
}
}
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
}
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(state, handler) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
}
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
}
}
val state = state ?: handler.scrollState
LaunchedEffect(state, handler) {
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
}
val connection = handler.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)
+100
View File
@@ -0,0 +1,100 @@
# 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.