Runtime shader can achieve many effects with hardw...
# compose
k
Runtime shader can achieve many effects with hardware acceleration. But there's an API restriction: only runs on Android 13+. I created a configurable ripple effect, inspired by WWDC24:

https://youtu.be/alhFwkbsxrs?t=1180&si=Li681RpLDrUMzjL_

👍 1
🤩 9
👏 9
🤯 6
👏🏾 1
r
Looks great!
h
Jello UI 💖
k
could you show the code? very nice
r
We have some content on applying RenderEffects to graphics layers in this article - https://medium.com/androiddevelopers/making-jellyfish-move-in-compose-animating-imagevectors-and-applying-agsl-rendereffects-3666596a8888 I'm assuming @Kyant has used a different shader that takes x,y position as input into the shader, right?
s
What if I told you that you can make it work on older Android versions with just a bit of OpenGL and some abstractions around it, using the same shader code? 👀
a
that looks insanely good 🤯
r
https://thebookofshaders.com/ is a great intro for anyone who wants to get into shaders
❤️ 1
👍 1
k
Copy code
@Language(value = "AGSL")
        val shader = """
uniform shader image;

uniform float2 origin;
uniform float elapsedTime;
uniform float amplitude;
uniform float frequency;
uniform float decay;
uniform float speed;
uniform float radius;
uniform float tintRatio;

float2 lerp(float2 a, float2 b, float t) {
    return a + t * (b - a);
}

// Note: we use easing to prevent deformation in the center

// Function to calculate the cubic Bezier curve value at t
vec2 cubicBezier(vec2 P0, vec2 P1, vec2 P2, vec2 P3, float t) {
    float u = 1.0 - t;
    float tt = t * t;
    float uu = u * u;
    float uuu = uu * u;
    float ttt = tt * t;

    vec2 p = uuu * P0;       // (1 - t)^3 * P0
    p += 3.0 * uu * t * P1;  // 3 * (1 - t)^2 * t * P1
    p += 3.0 * u * tt * P2;  // 3 * (1 - t) * t^2 * P2
    p += ttt * P3;           // t^3 * P3

    return p;
}

// Function to find t for a given x using the Newton-Raphson method
float findTForX(float x, vec2 P0, vec2 P1, vec2 P2, vec2 P3) {
    float t = x; // Initial guess
    for (int i = 0; i < 5; i++) { // Iterate a few times
        vec2 bezierPoint = cubicBezier(P0, P1, P2, P3, t);
        float x_t = bezierPoint.x;
        float dx_dt = -3.0 * (1.0 - t) * (1.0 - t) * P0.x + 3.0 * (1.0 - 2.0 * t) * (1.0 - t) * P1.x + 3.0 * t * (2.0 - 3.0 * t) * P2.x + 3.0 * t * t * P3.x;

        t -= (x_t - x) / dx_dt;
        t = clamp(t, 0.0, 1.0); // Keep t within [0, 1]
    }
    return t;
}

// Function to get the y value for a given x using the cubic Bezier curve
float getYForX(float x, vec2 P1, vec2 P2) {
    vec2 P0 = vec2(0.0, 0.0);  // Start point
    vec2 P3 = vec2(1.0, 1.0);  // End point

    float t = findTForX(x, P0, P1, P2, P3);
    vec2 bezierPoint = cubicBezier(P0, P1, P2, P3, t);

    return bezierPoint.y;
}

float transformEaseEmphasizedAccelerate(float x) {
    float2 P1 = float2(0.05, 0.7);
    float2 P2 = float2(0.1, 1.0);
    return getYForX(x, P1, P2);
}

half4 main(float2 coord) {
    float distance = length(coord - origin);
    float delay = distance / speed;
    float adjustedTime = max(0.0, elapsedTime - delay);

    float rippleAmount = amplitude * sin(frequency * adjustedTime) * exp(-decay * adjustedTime);
    float2 n = normalize(coord - origin);
    float2 newPosition = coord + rippleAmount * n;
    newPosition = lerp(coord, newPosition, transformEaseEmphasizedAccelerate(distance / radius));

    half4 color = image.eval(newPosition);
    if (amplitude != 0.0) {
        color.rgb += tintRatio * (rippleAmount / amplitude) * color.a;
    }
    return color;
}
"""
🎉 2
Copy code
@RequiresApi(33)
class RippleShader(
    val origin: Offset,
    val radius: Float,
    val amplitude: Float,
    val frequency: Float,
    val decay: Float,
    val relativeSpeed: Float,
    val tintRatio: Float,
    val startTimeMillis: Int,
    val fadeInDurationMillis: Int,
    val fadeOutDurationMillis: Int
) {
    internal var canceled = false
    private val elapsedTime = Animatable(startTimeMillis / 1000f, visibilityThreshold = 1f)

    private val runtimeShader = RuntimeShader(shader).apply {
        setFloatUniform("origin", origin.x, origin.y)
        setFloatUniform("elapsedTime", 0f)
        setFloatUniform("amplitude", amplitude)
        setFloatUniform("frequency", frequency)
        setFloatUniform("decay", decay)
        setFloatUniform("speed", radius * relativeSpeed)
        setFloatUniform("radius", radius)
        setFloatUniform("tintRatio", tintRatio)
    }

    var renderEffect by mutableStateOf(
        RenderEffect
            .createRuntimeShaderEffect(runtimeShader, "image")
            .asComposeRenderEffect()
    )
        private set

    suspend fun fadeIn() {
        elapsedTime.animateTo(
            (startTimeMillis + fadeInDurationMillis) / 1000f,
            tween(
                startTimeMillis + fadeInDurationMillis - (elapsedTime.value * 1000).fastRoundToInt(),
                0,
                LinearEasing
            )
        ) {
            runtimeShader.setFloatUniform("elapsedTime", value)
            renderEffect = RenderEffect
                .createRuntimeShaderEffect(runtimeShader, "image")
                .asComposeRenderEffect()
        }
    }

suspend fun fadeOut() {
    elapsedTime.animateTo(
        (startTimeMillis + fadeInDurationMillis + fadeOutDurationMillis) / 1000f,
        tween(fadeOutDurationMillis, 0, LinearEasing)
    ) {
        runtimeShader.setFloatUniform("elapsedTime", value)
        renderEffect = RenderEffect
            .createRuntimeShaderEffect(runtimeShader, "image")
            .asComposeRenderEffect()
    }
}
Here is one of config:
Copy code
Modifier
.graphicsLayer {
                renderEffect = shader?.renderEffect
                clip = true
            }
.onSizeChanged { size ->
    shader = RippleShader(
        origin = Offset(size.width / 2f, size.height / 1.25f),
        radius = sqrt(size.width.toFloat() * size.width + size.height * size.height) / 2f,
        amplitude = with(density) { (-16).dp.toPx() },
        frequency = 10f,
        decay = 10f,
        relativeSpeed = 6f,
        tintRatio = 0.3f,
        startTimeMillis = 0,
        fadeInDurationMillis = 0,
        fadeOutDurationMillis = 500
    )
}
And using shader.fadeIn / fadeOut
Note when there is a transparent background, the ripple won't add color on the transparent background. I don't know how to do it. maybe create a rectangle?
s
it might be easier to share the code all in a github gist 😄
k
😂 I am busy now
🙌 1
Hi, could you explain how to do it?
@Sergey Y.
To make it compatible
s
I’m a bit busy right now 😄, so here’s a brief overview: To make it compatible with older Android versions, render your composable content into an OpenGL texture using a GraphicsLayer. This allows hardware-accelerated rendering into an android texture, called a SurfaceTexture. For convenience when working with OpenGL, you can use GLRenderer from the AndroidX graphics library, a new Jetpack library. You’ll be manipulating the rendered pixels in the texture, applying the wavy shader, and seeing the result as a normal UI. This method is fast and hardware-accelerated. I’m working on a similar approach for my Blurring library. I’ve shared some progress here, but it’s still ongoing and needs more time.
👍 2
Yeah, that might sound confusing, but in practice, it’s fine if you know a bit of OpenGL. 😅
d
a
As I am new to shader, in agsl , the ripple shader doesn't deform the layer into wavy patterns and draws it outside of bounds also as a wavy layer. Rather if it is not clipped, the edge get stretched in all direction and if clipped the ripple appear only inside the bounds. Why this ? Any solution to this. Here's the image of what I got.
r
When it's not clipped you are sampling using coordinates that are outside of the original bounds. The default behavior in that case is to clamp to the edge, causing what you are seeing.
You either need to clip or handle this case in your shader
a
Yeah, I added this check and it works as expected but shouldn't Android provide us the tile mode option or handle this automatically ?
Copy code
if (newPosition.x < 0.0 ||
    newPosition.y < 0.0 ||
    newPosition.x >= size.x ||
    newPosition.y >= size.y) {
    return vec4(0.0);
}
115 Views