Use GPU compositing for highlight and shadow.

This is a compromise. The overall performance is improved. But non-lazy scrollable layouts can be surprisingly laggy on device with poor GPU performance.
This commit is contained in:
Kyant0 committed 2025-09-24 23:47:20 +08:00
1 parent 455f81b221
commit 86ab3fdc0e
10 files changed
+198 -244

No files matched your search

@@ -1,34 +1,26 @@
package com.kyant.backdrop.highlight
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.BlendMode
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.RectF
import android.graphics.RuntimeShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePaint
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.isUnspecified
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireGraphicsContext
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.util.fastCoerceAtMost
import com.kyant.backdrop.RuntimeShaderCacheScope
@@ -47,7 +39,7 @@ internal class HighlightElement(
override fun update(node: HighlightNode) {
node.shapeProvider = shapeProvider
node.highlight = highlight
node.invalidateDrawCache()
node.invalidateDraw()
}
override fun InspectorInfo.inspectableProperties() {
@@ -76,19 +68,17 @@ internal class HighlightElement(
internal class HighlightNode(
var shapeProvider: ShapeProvider,
var highlight: () -> Highlight?
) : DrawModifierNode, ObserverModifierNode, Modifier.Node() {
) : DrawModifierNode, Modifier.Node() {
override val shouldAutoInvalidate: Boolean = false
private var bitmap: Bitmap? = null
private var canvas: Canvas? = null
private var bitmapShader: BitmapShader? = null
private var highlightLayer: GraphicsLayer? = null
private var maskLayer: GraphicsLayer? = null
private val paint =
Paint().apply {
style = Paint.Style.STROKE
}
private val shaderPaint = Paint()
private var prevSize = Size.Unspecified
private var clipPath: Path? = null
private val runtimeShaderCacheScope = object : RuntimeShaderCacheScope {
@@ -101,66 +91,81 @@ internal class HighlightNode(
}
}
private var isCacheValid = false
private var _highlight: Highlight? = null
private val cacheDrawBlock: DrawScope.() -> Unit = cacheDrawBlock@{
val highlight = highlight().also { _highlight = it }
val size = size
if (highlight == null ||
highlight.width.value <= 0f || highlight.color.isUnspecified || highlight.alpha == 0f ||
size.width < 0.5f || size.height < 0.5f
) {
_highlight = null
return@cacheDrawBlock
private var prevStyle: HighlightStyle? = null
override fun ContentDrawScope.draw() {
val highlight = highlight()
if (highlight == null || highlight.width.value <= 0f || highlight.color.isUnspecified) {
return drawContent()
}
val color =
if (highlight.alpha == 1f) {
highlight.color
} else {
highlight.color.copy(alpha = highlight.color.alpha * highlight.alpha)
drawContent()
val highlightLayer = highlightLayer
val maskLayer = maskLayer
if (highlightLayer != null && maskLayer != null) {
val size = size
val outline = shapeProvider.shape.createOutline(size, layoutDirection, this)
configurePaint(highlight)
val style = highlight.style()
if (prevStyle != style) {
highlightLayer.renderEffect =
with(highlight.style()) { runtimeShaderCacheScope.createRenderEffect(size) }
prevStyle = style
}
paint.color = color.toArgb()
val strokeWidth = ceil(highlight.width.toPx().fastCoerceAtMost(size.minDimension / 2f))
paint.strokeWidth = strokeWidth * 2f
highlightLayer.record { drawHighlight(outline) }
maskLayer.alpha = highlight.alpha
maskLayer.blendMode = highlight.blendMode
maskLayer.record { drawLayer(highlightLayer) }
drawLayer(maskLayer)
}
}
override fun onAttach() {
val graphicsContext = requireGraphicsContext()
highlightLayer = graphicsContext.createGraphicsLayer()
maskLayer =
graphicsContext.createGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
}
override fun onDetach() {
val graphicsContext = requireGraphicsContext()
highlightLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
highlightLayer = null
}
maskLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
maskLayer = null
}
clipPath = null
prevStyle = null
}
private fun DrawScope.configurePaint(highlight: Highlight) {
paint.color = highlight.color.toArgb()
paint.strokeWidth =
ceil(highlight.width.toPx().fastCoerceAtMost(size.minDimension / 2f)) * 2f
val blurRadius = highlight.blurRadius.toPx()
if (blurRadius > 0f) {
paint.maskFilter = BlurMaskFilter(blurRadius, BlurMaskFilter.Blur.NORMAL)
}
shaderPaint.asComposePaint().blendMode = highlight.blendMode
}
if (prevSize != size) {
bitmap = null
canvas = null
prevSize = size
}
@Suppress("UseKtx")
val bitmap =
bitmap ?: Bitmap.createBitmap(
ceil(size.width).toInt(),
ceil(size.height).toInt(),
Bitmap.Config.ARGB_8888
).also {
bitmap = it
bitmapShader = BitmapShader(it, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
val canvas =
canvas?.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
drawColor(Color.TRANSPARENT, BlendMode.CLEAR)
} else {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
}
} ?: Canvas(bitmap).also { canvas = it }
private fun DrawScope.drawHighlight(outline: Outline) {
val canvas = drawContext.canvas.nativeCanvas
canvas.save()
when (val outline = shapeProvider.shape.createOutline(size, layoutDirection, this)) {
when (outline) {
is Outline.Rectangle -> {
val rect = RectF(0f, 0f, size.width, size.height)
canvas.clipRect(rect)
canvas.drawRect(rect, paint)
canvas.clipRect(0f, 0f, size.width, size.height)
canvas.drawRect(0f, 0f, size.width, size.height, paint)
}
is Outline.Rounded -> {
@@ -170,12 +175,12 @@ internal class HighlightNode(
canvas.clipPath(path)
canvas.drawPath(path, paint)
} else {
val rect = with(outline.roundRect) { RectF(left, top, right, bottom) }
val rr = outline.roundRect
val radius = outline.roundRect.topLeftCornerRadius.x
val clipPath = clipPath?.apply { rewind() } ?: Path().also { clipPath = it }
clipPath.addRoundRect(rect, radius, radius, Path.Direction.CW)
clipPath.addRoundRect(rr.left, rr.top, rr.right, rr.bottom, radius, radius, Path.Direction.CW)
canvas.clipPath(clipPath)
canvas.drawRoundRect(rect, radius, radius, paint)
canvas.drawRoundRect(rr.left, rr.top, rr.right, rr.bottom, radius, radius, paint)
}
}
@@ -188,38 +193,4 @@ internal class HighlightNode(
canvas.restore()
}
override fun ContentDrawScope.draw() {
if (!isCacheValid) {
observeReads { cacheDrawBlock() }
isCacheValid = true
}
drawContent()
val highlight = _highlight
val bitmap = bitmap
val bitmapShader = bitmapShader
if (highlight != null && bitmap != null && bitmapShader != null) {
val maskShader =
with(highlight.style()) { runtimeShaderCacheScope.createShader(size, bitmapShader) }
shaderPaint.shader = maskShader
val canvas = drawContext.canvas.nativeCanvas
if (maskShader != null) {
canvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
} else {
canvas.drawBitmap(bitmap, 0f, 0f, shaderPaint)
}
}
}
override fun onObservedReadsChanged() {
invalidateDrawCache()
}
fun invalidateDrawCache() {
isCacheValid = false
invalidateDraw()
}
}
@@ -1,12 +1,13 @@
package com.kyant.backdrop.highlight
import android.graphics.BitmapShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.FloatRange
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.AmbientHighlightShaderString
import com.kyant.backdrop.DynamicHighlightShaderString
@@ -16,18 +17,14 @@ import kotlin.math.PI
@Immutable
sealed interface HighlightStyle {
fun RuntimeShaderCacheScope.createShader(
size: Size,
bitmapShader: BitmapShader
): Shader?
@RequiresApi(Build.VERSION_CODES.S)
fun RuntimeShaderCacheScope.createRenderEffect(size: Size): RenderEffect?
@Immutable
data object Solid : HighlightStyle {
override fun RuntimeShaderCacheScope.createShader(
size: Size,
bitmapShader: BitmapShader
): Shader? = null
@RequiresApi(Build.VERSION_CODES.S)
override fun RuntimeShaderCacheScope.createRenderEffect(size: Size): RenderEffect? = null
}
@Immutable
@@ -37,10 +34,8 @@ sealed interface HighlightStyle {
val isAmbient: Boolean = false
) : HighlightStyle {
override fun RuntimeShaderCacheScope.createShader(
size: Size,
bitmapShader: BitmapShader
): Shader? {
@RequiresApi(Build.VERSION_CODES.S)
override fun RuntimeShaderCacheScope.createRenderEffect(size: Size): RenderEffect? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val shader =
if (isAmbient) {
@@ -49,11 +44,12 @@ sealed interface HighlightStyle {
obtainRuntimeShader("Dynamic", DynamicHighlightShaderString)
}
shader.apply {
setInputBuffer("image", bitmapShader)
setFloatUniform("size", size.width, size.height)
setFloatUniform("angle", angle * (PI / 180f).toFloat())
setFloatUniform("falloff", falloff)
}
android.graphics.RenderEffect.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
} else {
null
}
@@ -14,7 +14,7 @@ import androidx.compose.ui.util.lerp
@Immutable
data class Shadow(
val elevation: Dp = 24f.dp,
val radius: Dp = 24f.dp,
val offset: DpOffset = DpOffset(0f.dp, 4f.dp),
val color: Color = Color.Black.copy(alpha = 0.15f),
val alpha: Float = 1f,
@@ -31,7 +31,7 @@ data class Shadow(
@Stable
fun lerp(start: Shadow, stop: Shadow, fraction: Float): Shadow {
return Shadow(
elevation = lerp(start.elevation, stop.elevation, fraction),
radius = lerp(start.radius, stop.radius, fraction),
offset = lerp(start.offset, stop.offset, fraction),
color = lerp(start.color, stop.color, fraction),
alpha = lerp(start.alpha, stop.alpha, fraction),
@@ -1,30 +1,27 @@
package com.kyant.backdrop.shadow
import android.graphics.Bitmap
import android.graphics.BlendMode
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.os.Build
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePaint
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.isUnspecified
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireGraphicsContext
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.IntSize
import com.kyant.backdrop.ShapeProvider
@@ -42,7 +39,7 @@ internal class ShadowElement(
override fun update(node: ShadowNode) {
node.shapeProvider = shapeProvider
node.shadow = shadow
node.invalidateDrawCache()
node.invalidateDraw()
}
override fun InspectorInfo.inspectableProperties() {
@@ -71,92 +68,96 @@ internal class ShadowElement(
internal class ShadowNode(
var shapeProvider: ShapeProvider,
var shadow: () -> Shadow?
) : DrawModifierNode, ObserverModifierNode, Modifier.Node() {
) : DrawModifierNode, Modifier.Node() {
override val shouldAutoInvalidate: Boolean = false
private var bitmap: Bitmap? = null
private var canvas: Canvas? = null
private val paint =
Paint().apply {
color = Color.TRANSPARENT
}
private val maskPaint =
Paint().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
blendMode = BlendMode.CLEAR
} else {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
private var shadowLayer: GraphicsLayer? = null
private var maskLayer: GraphicsLayer? = null
private val shadowPaint = Paint()
private var prevRadius = Float.NaN
override fun ContentDrawScope.draw() {
val shadow = shadow() ?: return drawContent()
val shadowLayer = shadowLayer
val maskLayer = maskLayer
if (shadowLayer != null && maskLayer != null) {
val size = size
val radius = shadow.radius.toPx()
val offsetX = shadow.offset.x.toPx()
val offsetY = shadow.offset.y.toPx()
val shadowSize = IntSize(
ceil(size.width + radius * 2 + offsetX).toInt(),
ceil(size.height + radius * 2 + offsetY).toInt()
)
val outline = shapeProvider.shape.createOutline(size, layoutDirection, this)
configurePaint(shadow)
if (prevRadius != radius) {
shadowLayer.renderEffect =
if (radius > 0f) {
BlurEffect(radius, radius, TileMode.Decal)
} else {
null
}
prevRadius = radius
}
}
private val drawPaint = Paint()
private var prevSize = Size.Unspecified
private var dx = 0f
private var dy = 0f
shadowLayer.record { drawOutline(outline, shadowPaint) }
private var _shadow: Shadow? = null
private var isCacheValid = false
private val cacheDrawBlock: DrawScope.() -> Unit = cacheDrawBlock@{
val shadow = shadow().also { _shadow = it }
val size = size
if (shadow == null ||
shadow.elevation.value <= 0f || shadow.color.isUnspecified || shadow.alpha == 0f
) {
_shadow = null
return@cacheDrawBlock
}
val elevation = shadow.elevation.toPx()
val offset = Offset(shadow.offset.x.toPx(), shadow.offset.y.toPx())
val shadowSize = IntSize(
ceil(size.width + elevation * 2).toInt(),
ceil(size.height + elevation * 2).toInt()
)
if (shadowSize.width < 0.5f || shadowSize.height < 0.5f) {
_shadow = null
return@cacheDrawBlock
}
val color =
if (shadow.alpha == 1f) {
shadow.color
} else {
shadow.color.copy(alpha = shadow.color.alpha * shadow.alpha)
}
paint.setShadowLayer(elevation, offset.x, offset.y, color.toArgb())
drawPaint.asComposePaint().blendMode = shadow.blendMode
if (prevSize != size) {
bitmap = null
canvas = null
prevSize = size
}
@Suppress("UseKtx")
val bitmap =
bitmap ?: Bitmap.createBitmap(
shadowSize.width,
shadowSize.height,
Bitmap.Config.ARGB_8888
).also { bitmap = it }
val canvas =
canvas?.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
drawColor(Color.TRANSPARENT, BlendMode.CLEAR)
} else {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
maskLayer.blendMode = shadow.blendMode
maskLayer.record(size = shadowSize) {
translate(radius + offsetX, radius + offsetY) {
drawLayer(shadowLayer)
translate(-offsetX, -offsetY) {
drawOutline(outline, ShadowMaskPaint)
}
}
} ?: Canvas(bitmap).also { canvas = it }
}
dx = elevation - offset.x
dy = elevation - offset.y
canvas.translate(dx, dy)
translate(-radius, -radius) {
drawLayer(maskLayer)
}
}
when (val outline = shapeProvider.shape.createOutline(size, layoutDirection, this)) {
drawContent()
}
override fun onAttach() {
val graphicsContext = requireGraphicsContext()
shadowLayer = graphicsContext.createGraphicsLayer()
maskLayer =
graphicsContext.createGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
}
override fun onDetach() {
val graphicsContext = requireGraphicsContext()
shadowLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
shadowLayer = null
}
maskLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
maskLayer = null
}
prevRadius = Float.NaN
}
private fun DrawScope.configurePaint(shadow: Shadow) {
shadowPaint.color = shadow.color.modulate(shadow.alpha).toArgb()
}
private fun DrawScope.drawOutline(outline: Outline, paint: Paint) {
val canvas = drawContext.canvas.nativeCanvas
when (outline) {
is Outline.Rectangle -> {
val rect = RectF(0f, 0f, size.width, size.height)
canvas.drawRect(rect, paint)
canvas.drawRect(rect, maskPaint)
canvas.drawRect(0f, 0f, size.width, size.height, paint)
}
is Outline.Rounded -> {
@@ -164,46 +165,32 @@ internal class ShadowNode(
val path = outline.roundRectPath?.asAndroidPath()
if (path != null) {
canvas.drawPath(path, paint)
canvas.drawPath(path, maskPaint)
} else {
val rect = with(outline.roundRect) { RectF(left, top, right, bottom) }
val rr = outline.roundRect
val radius = outline.roundRect.topLeftCornerRadius.x
canvas.drawRoundRect(rect, radius, radius, paint)
canvas.drawRoundRect(rect, radius, radius, maskPaint)
canvas.drawRoundRect(rr.left, rr.top, rr.right, rr.bottom, radius, radius, paint)
}
}
is Outline.Generic -> {
val path = outline.path.asAndroidPath()
canvas.drawPath(path, paint)
canvas.drawPath(path, maskPaint)
}
}
canvas.translate(-dx, -dy)
}
override fun ContentDrawScope.draw() {
if (!isCacheValid) {
observeReads { cacheDrawBlock() }
isCacheValid = true
}
val bitmap = bitmap
if (bitmap != null) {
val canvas = drawContext.canvas.nativeCanvas
canvas.drawBitmap(bitmap, -dx, -dy, drawPaint)
}
drawContent()
}
override fun onObservedReadsChanged() {
invalidateDrawCache()
}
fun invalidateDrawCache() {
isCacheValid = false
invalidateDraw()
}
}
private val ShadowMaskPaint = Paint().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
blendMode = android.graphics.BlendMode.CLEAR
} else {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
}
private fun Color.modulate(alpha: Float): Color =
if (alpha != 1.0f) {
copy(alpha = this.alpha * alpha)
} else {
this
}