diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt new file mode 100644 index 0000000000..bae80e11d0 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/BarsBlur.android.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt new file mode 100644 index 0000000000..a7bd229d7f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/BarsBlur.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index c7553b6ed0..6b39c80058 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -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, keyboardCoversBar: Boolean, onTop: Boolean, + handler: AppBarHandler, graphicsLayer: GraphicsLayer, backgroundGraphicsLayer: GraphicsLayer, backgroundGraphicsLayerSize: State, 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) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index ad6611b9d9..3e29649517 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -78,6 +78,10 @@ class AppBarHandler( val backgroundGraphicsLayerSize: MutableState = 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 = mutableStateOf(0) + companion object { var appBarMaxHeightPx: Int = 0 } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt new file mode 100644 index 0000000000..6c4ee704bb --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/BarsBlur.desktop.kt @@ -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() } + +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 + ) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 696e0efde8..2ebec123ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -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) diff --git a/plans/desktop-bars-blur-performance.md b/plans/desktop-bars-blur-performance.md new file mode 100644 index 0000000000..4e113c90cb --- /dev/null +++ b/plans/desktop-bars-blur-performance.md @@ -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.