Hi there folks! I'm running into some issues whil...
# compose-android
m
Hi there folks! I'm running into some issues while trying to update my pet project to the latest Compose version. You can check it out here: https://github.com/manuel-martos/creative-lab. I've been using AGSL to create some cool effects. For example, I've got a Compose clock with a slick reveal effect, all thanks to AGSL (see attached video). Everything was working super smooth on Compose before version 1.5. But now, when I update to the latest version, the refresh rate takes a nosedive to just 1 fps. 🐒 You can take a look at the code for this effect here. The issue seems to be related to how I update the
time
on the AGSL shader. I've been using
graphicsLayer
modifier to update it, and for some reason, the new modifier system doesn't seem to handle it as smoothly as it used to before the update. Any of you fine folks run into similar issues? I've found some workarounds, like wrapping
StrokedClock
with
key(time)
, but it feels like overkill. What's the best way to achieve the same results? Thanks in advance for any help! πŸ™Œ
r
Why is there a graphicsLayer? It's not needed for what you are doing
m
If I remove
graphicsLayer
and call
updateTime
from the outer scope issue persist
Alternatively, if I just add
time
to
bandShader
remember and
bandShader
to
shaderBrush
remember it works:
Copy code
@Composable
fun StrokedClock(
    clock: String,
    time: Float,
    modifier: Modifier,
) {
    BoxWithConstraints(modifier = modifier) {
        val bandShader = remember(time) {
            PulseBandShader().apply {
                updateResolution(
                    Size(
                        constraints.maxWidth.toFloat(),
                        constraints.maxHeight.toFloat()
                    )
                )
                updateTime(time)
            }
        }
        val shaderBrush = remember(bandShader) { ShaderBrush(bandShader) }
        Text(
            text = clock,
            color = MaterialTheme.colorScheme.onSurface,
            style = TextStyle.Default.copy(
                textAlign = TextAlign.Center,
                fontSize = 72.sp,
                fontWeight = FontWeight.Bold,
                fontFamily = fontFamily,
                drawStyle = Stroke(
                    miter = 10f,
                    width = 5f,
                    join = StrokeJoin.Round
                ),
                brush = shaderBrush,
            ),
        )
    }
}
But I feel like this solution have a huge impact in memory consumption and gc too. Prev version didn't needed none of this tweaks. Is there any trick I can apply to redraw
Text
composable with updated
shaderBrush
and
bandShader
without impacting in memory footprint?
s
It seems like you are using time as input for your composable, which recomposes every tick. Together with capturing this param in lambdas, you are recreating the whole modifier chain. What you want to do instead is to start FrameEffect next to shader definition and assign time directly in that effect
m
Hey @shikasd! I've applied proposed changes and animation isn't working yet 😞 . Here you have the code snippet:
Copy code
@Composable
fun StrokedClock(
    modifier: Modifier,
) {
    BoxWithConstraints(modifier = modifier) {
        var localDate by remember { mutableStateOf(LocalDateTime.now()) }
        var time by remember { mutableFloatStateOf(0f) }
        val bandShader = remember {
            PulseBandShader().apply {
                updateResolution(
                    Size(
                        constraints.maxWidth.toFloat(),
                        constraints.maxHeight.toFloat()
                    )
                )
            }
        }
        val shaderBrush = remember { ShaderBrush(bandShader) }
        FrameEffect(Unit, initialValue = localDate.nano / 1_000_000_000f) {
            time = it
            localDate = LocalDateTime.now()
            bandShader.updateTime(time)
        }
        val formatter = remember { DateTimeFormatter.ofPattern("HH:mm:ss") }
        val formattedTime by remember { derivedStateOf { formatter.format(localDate) } }
        Text(
            text = formattedTime,
            color = MaterialTheme.colorScheme.onSurface,
            style = TextStyle.Default.copy(
                textAlign = TextAlign.Center,
                fontSize = 72.sp,
                fontWeight = FontWeight.Bold,
                fontFamily = fontFamily,
                drawStyle = Stroke(
                    miter = 10f,
                    width = 5f,
                    join = StrokeJoin.Round
                ),
                brush = shaderBrush,
            ),
        )
    }
}
s
hm, you might need to invalidate drawing for the component by using draw modifier and reading state in it, I assumed that update to shader would redraw things try adding this modifier to text:
Copy code
Modifier.drawWithContent {
  time.value // just read the state so Compose knows to redraw
  drawContent()
}
m
I've tried out your suggestion, but unfortunately, I haven't had any success so far. I'm not sure how to make the redraws happen when updating shader uniforms. 😞 It could be that the latest Compose compiler optimisations are making it impossible.
s
I don't think Compose compiler optimized anything to make it impossible. My guess is that something is not updating in on the draw layer (might be a bug) and that prevents redraw from happening. I also couldn't reproduce the slowdown to 1fps you mentioned in the original post, it just stops updating for me in the new versions.
m
To observe the decrease to 1fps, you can simply add 0.5 when updating the time:
Copy code
bandShader.updateTime(time + 0.5f)
Since the band shader is designed to last 1 second and the drawing begins completely cleared, this might be why you're not seeing anything at all.
s
Ah, I was only looking at the noise one, thanks
This is outside of my expertise entirely, but I experimented a little bit with it. It seems like the trick is to make sure you recreate the brush every time you want the shader to be updated, which sounds a bit... wasteful? E.g. in your example, you do:
Copy code
@Composable
fun NoiseReveal(
    modifier: Modifier = Modifier
) {
    val shader = remember { NoiseRevealShader() }
    val brush = remember { ShaderBrush(shader) }
    Text(
        ...,
        style = TextStyle(brush)
    ) 
}
I would expect that adding something like this should be enough, as we read
time
in draw pass, invalidating it:
Copy code
Modifier.drawWithContent { 
    shader.update(/* size / time */)
    drawContent()
}
But the only way I managed to make it work is recreating the brush whenever time changes:
Copy code
var time by remember { mutableFloatStateOf(0f) }
    val shader = remember { NoiseRevealShader() }
    FrameEffect(Unit) {
        time = it
    }
    // This causes recomposition (reading `time`) to recreate and re-set the brush
    // If the brush is not recreated, the change is not applied.
    shader.updateTime(time)
    val brush = ShaderBrush(shader)

    Text(
        text = "Hello from Creative Lab!",
        color = MaterialTheme.colorScheme.onSurface,
        style = TextStyle(
            textAlign = TextAlign.Center,
            fontSize = 40.sp,
            fontFamily = fontFamily,
            fontWeight = FontWeight.Bold,
            brush = brush
        ),
        modifier = modifier
            .onSizeChanged {
                shader.updateResolution(it.toSize())
            }
    )
@Nader Jawad maybe you can suggest a way to avoid recreating
ShaderBrush
in this case and only invalidate draw for each animation tick?
Also @Halil Ozercan who does text-shader stuff pretty often πŸ™‚
h
@shikasd you are right about all the assumptions you've made πŸ™‚ Unfortunately, there's a bug in Text Brush which makes it impossible to redraw on ShaderBrush animations, you simply need to recreate the ShaderBrush itself to trigger a redraw. What happens under the hood is that ShaderBrush creates an internal shader with the given size after layout. Then, the internal cache only checks for size changes, and does not care about snapshot reads happening in
createShader
function. We solved this for Brush span in
AnnotatedString
using
derivedStateOf
to wrap
createShader
call but I haven't got to fix it for brush parameter in
TextStyle
yet.
πŸ‘€ 1
s
So does that mean we still need to recreate the shader for every uniform update if I use AnnotatedString? Or it is reasonable to reuse the same instance and just update size uniform in
createShader
function?
h
There's a trick you can use to not recreate the shader for every uniform update πŸ™‚
Copy code
val shaderBrush = remember {
    object : ShaderBrush() {
        override fun createShader(size: Size): Shader {
            bandShader.updateTime(time)
            return bandShader
        }
    }
}
πŸ”₯ 1
Otherwise there's no way of telling whether uniforms have changed to trigger a redraw
s
Yeah, that's what I thought, thanks!
h
and I kinda misspoke, the fix hasn't even landed yet for
AnnotatedString
. It's gonna be available in the next
ui
module release hopefully.
s
Ah, ok, won't even try it then :D
m
Hey! Thank you all for your invaluable help! I've finally got a working version! I've updated my repository with it in case you want to take a look πŸ˜‰