AGSL Shaders in Jetpack Compose - Liquid Glass Effect

In this tutorial, we'll build an interactive Liquid Glass effect using Jetpack Compose and AGSL (Android Graphics Shading Language) that features chromatic aberration, lens distortion, and touch interaction.

What We'll Build

We're creating an interactive magnifying glass effect that:

  • ✨ Applies chromatic aberration (color separation for a prismatic effect)
  • 🔍 Creates lens distortion (magnification)
  • 👆 Follows touch input
  • 💎 Renders beautiful lighting and reflections
  • 🎨 Works on any image or content

Important Note on API Levels

The RuntimeShader API that powers this effect is only available on Android 13 (API 33) and above.

Your app can have a lower minSdk (like API 21), but the shader effect will only work on devices running Android 13+. For devices below API 33, you'll need to provide a fallback UI (simple visual indicators, blur effects, ripple effects... etc).

Understanding AGSL

AGSL (Android Graphics Shading Language) is Android's shader language based on GLSL. Starting from Android 13, you can use RuntimeShader to run custom shaders in Compose. Shaders are programs that run on the GPU to create visual effects. They are compiled and executed by Android's RenderThread (GPU pipeline).

Step 1: Shader code

Let's start by creating our AGSL shader. Create a new file LiquidGlassShader.kt:

package dev.jorgecastillo.composeshaders

const val LIQUID_GLASS_SHADER = """
    // ===== Uniforms (inputs from Kotlin) =====
    uniform float2 resolution;      // Canvas size (width, height)
    uniform float2 mouse;           // Touch position (in pixels)
    uniform shader image;           // Image to distort
    
    // ===== Effect Parameters =====
    const float REFRACTIVE_INDEX = 1.5;          // Glass refraction (1.0 = none, higher = more magnification)
    const float CHROMATIC_ABERRATION = 0.02;     // Color separation strength (0.0 = none)
    const float LENS_SIZE_MULTIPLIER = 200000.0; // Higher = smaller lens
    const float BLUR_SAMPLES = 4.0;              // Blur kernel half-size (4 = 9x9 grid)
    
    half4 main(float2 fragCoord) {
        // ===== Step 1: Calculate normalized coordinates =====
        // Convert pixel position to 0-1 range for easier math
        vec2 textureCoords = fragCoord / resolution.xy;
        
        // Convert mouse position to 0-1 range and get distance from current pixel
        vec2 mouseNormalized = mouse / resolution.xy;
        vec2 distanceFromMouse = textureCoords - mouseNormalized;
        
        // ===== Step 2: Create rounded rectangle shape using distance field =====
        // We use pow(abs(x), 8.0) to create smooth rounded corners
        // High exponent (8.0) creates sharper corners than lower values
        
        // Adjust x-axis distance by aspect ratio to maintain circular shape
        float aspectRatio = resolution.x / resolution.y;
        float distanceX = distanceFromMouse.x * aspectRatio;
        
        // Calculate distance field: pow() creates the rounded box shape
        // The higher the exponent, the more square-like the corners
        float distanceField = pow(abs(distanceX), 8.0) + pow(abs(distanceFromMouse.y), 8.0);
        
        // ===== Step 3: Create lens regions using the distance field =====
        // These masks define different parts of the lens effect
        
        // Main lens body: smooth falloff from center
        // Formula: (1.0 - distance * size) * sharpness
        // - High multiplier (200000) makes a small lens
        // - * 8.0 creates sharp edges
        float lensBody = clamp((1.0 - distanceField * LENS_SIZE_MULTIPLIER) * 8.0, 0.0, 1.0);
        
        // Lens border/edge highlight: creates a bright ring around the lens
        // We create this by subtracting two similar masks with different thresholds
        float borderOuter = clamp((0.95 - distanceField * (LENS_SIZE_MULTIPLIER * 0.95)) * 16.0, 0.0, 1.0);
        float borderInner = clamp(pow(0.9 - distanceField * (LENS_SIZE_MULTIPLIER * 0.95), 1.0) * 16.0, 0.0, 1.0);
        float lensBorder = borderOuter - borderInner;
        
        // Shadow/depth gradient: adds 3D appearance with soft shadow
        float shadowOuter = clamp((1.5 - distanceField * (LENS_SIZE_MULTIPLIER * 1.1)) * 2.0, 0.0, 1.0);
        float shadowInner = clamp(pow(1.0 - distanceField * (LENS_SIZE_MULTIPLIER * 1.1), 1.0) * 2.0, 0.0, 1.0);
        float shadowGradient = shadowOuter - shadowInner;
        
        vec4 finalColor = vec4(0.0);
        
        // ===== Step 4: Apply lens effect if inside lens area =====
        if (lensBody + lensBorder > 0.0) {
            
            // === 4a. Calculate lens distortion (magnification) ===
            // Move origin to center (0.5, 0.5), apply distortion, move back
            vec2 centeredCoords = textureCoords - 0.5;
            
            // Calculate distortion amount based on distance from lens center
            // Closer to center = more magnification
            float distortionAmount = 1.0 + (REFRACTIVE_INDEX - 1.0) * (1.0 - distanceField * 100000.0);
            vec2 distortedCoords = centeredCoords * distortionAmount + 0.5;
            
            // === 4b. Calculate chromatic aberration offset ===
            // This separates RGB channels to create rainbow fringing effect
            vec2 aberrationOffset = CHROMATIC_ABERRATION * distanceFromMouse;
            
            // === 4c. Apply blur with chromatic aberration ===
            // We sample the image multiple times in a grid pattern for smooth blur
            float sampleCount = 0.0;
            
            // Loop creates a 9x9 grid of samples (from -4 to +4 on both axes)
            for (float x = -BLUR_SAMPLES; x <= BLUR_SAMPLES; x++) {
                for (float y = -BLUR_SAMPLES; y <= BLUR_SAMPLES; y++) {
                    // Calculate offset for this sample (in normalized coordinates)
                    vec2 sampleOffset = vec2(x, y) * 0.5 / resolution.xy;
                    
                    // Sample each color channel at slightly different positions
                    // This creates the chromatic aberration (color fringing) effect
                    vec3 sampledColor;
                    
                    // Red channel: shifted in direction of aberration
                    vec2 redSamplePos = (sampleOffset + distortedCoords + aberrationOffset) * resolution;
                    sampledColor.r = image.eval(redSamplePos).r;
                    
                    // Green channel: no shift (center position)
                    vec2 greenSamplePos = (sampleOffset + distortedCoords) * resolution;
                    sampledColor.g = image.eval(greenSamplePos).g;
                    
                    // Blue channel: shifted opposite direction from red
                    vec2 blueSamplePos = (sampleOffset + distortedCoords - aberrationOffset) * resolution;
                    sampledColor.b = image.eval(blueSamplePos).b;
                    
                    finalColor += vec4(sampledColor, 1.0);
                    sampleCount += 1.0;
                }
            }
            
            // Average all samples to get final blurred color
            finalColor /= sampleCount;
            
            // === 4d. Add lighting effects for realism ===
            // Top highlight: simulates light reflection on top of glass
            float topHighlight = clamp((clamp(distanceFromMouse.y, 0.0, 0.2) + 0.1) / 2.0, 0.0, 1.0);
            
            // Bottom shadow: adds depth with subtle shadow
            float bottomShadow = clamp((clamp(-distanceFromMouse.y, -1000.0, 0.2) * shadowGradient + 0.1) / 2.0, 0.0, 1.0);
            
            float lightingGradient = topHighlight + bottomShadow;
            
            // Combine distorted image + gradient lighting + border highlight
            finalColor = clamp(finalColor + vec4(lensBody) * lightingGradient + vec4(lensBorder) * 0.3, 0.0, 1.0);
            
        } else {
            // ===== Step 5: Outside lens - show original image =====
            finalColor = image.eval(fragCoord);
        }
        
        return half4(finalColor);
    }
"""

Shader Breakdown

If you have no experience with shaders, this code can be a bit overwhelming. Let's understand each part:

Step 1: Coordinate Normalization

Converts pixel positions to normalized 0-1 range and calculates how far each pixel is from the mouse/touch position.

vec2 textureCoords = fragCoord / resolution.xy;
vec2 mouseNormalized = mouse / resolution.xy;
vec2 distanceFromMouse = textureCoords - mouseNormalized;

Step 2: Distance Field for Lens Shape

float distanceField = pow(abs(distanceX), 8.0) + pow(abs(distanceFromMouse.y), 8.0);

Why pow(x, 8.0)?

  • Creates a superellipse (rounded rectangle)
  • Low exponents (2.0) = circle
  • High exponents (8.0+) = square with rounded corners
  • The higher the exponent, the sharper the corners
  • abs() makes it symmetric around the center

Step 3: Creating Lens Regions

Three masks define the glass appearance:

  1. lensBody - Main glass area with smooth falloff
    float lensBody = clamp((1.0 - distanceField * 200000.0) * 8.0, 0.0, 1.0);
    • Large multiplier (200000) creates a small lens
    • * 8.0 sharpens the edge transition
  2. lensBorder - Bright highlight ring
    float borderOuter = clamp((0.95 - distanceField * (LENS_SIZE_MULTIPLIER * 0.95)) * 16.0, 0.0, 1.0);
    float borderInner = clamp(pow(0.9 - distanceField * (LENS_SIZE_MULTIPLIER * 0.95), 1.0) * 16.0, 0.0, 1.0);
    float lensBorder = borderOuter - borderInner;
    • Subtracting two shifted masks creates a ring
    • Simulates light reflecting off the glass edge
  3. shadowGradient - Depth and 3D appearance
    float shadowOuter = clamp((1.5 - distanceField * (LENS_SIZE_MULTIPLIER * 1.1)) * 2.0, 0.0, 1.0);
    float shadowInner = clamp(pow(1.0 - distanceField * (LENS_SIZE_MULTIPLIER * 1.1), 1.0) * 2.0, 0.0, 1.0);
    float shadowGradient = shadowOuter - shadowInner;
    • Adds subtle shadows for realism

Step 4: Optical Effects

4a. Lens Distortion (Magnification)

vec2 centeredCoords = textureCoords - 0.5;
float distortionAmount = 1.0 + (REFRACTIVE_INDEX - 1.0) * (1.0 - distanceField * 100000.0);
vec2 distortedCoords = centeredCoords * distortionAmount + 0.5;
  • Centers coordinates around (0.5, 0.5)
  • Scales based on distance from lens center
  • Recenters to original position
  • Result: Content appears magnified under the lens

4b. Chromatic Aberration

sampledColor.r = image.eval(redSamplePos + aberrationOffset).r;
sampledColor.g = image.eval(greenSamplePos).g;
sampledColor.b = image.eval(blueSamplePos - aberrationOffset).b;
  • Red channel: Shifted in one direction
  • Green channel: No shift (center)
  • Blue channel: Shifted opposite direction from red
  • Creates rainbow-like color fringing (like real glass prisms)

4c. Blur

  • 9x9 sampling grid (81 total samples)
  • Averages colors from neighboring pixels
  • Creates smooth, frosted glass appearance

4d. Lighting

float topHighlight = ...;  // Simulates light reflection on top
float bottomShadow = ...;  // Adds depth with shadow
  • Gradients follow the Y-axis
  • Top is brighter (light reflection)
  • Bottom is darker (shadow)
  • Makes the flat 2D effect look 3D

Step 2: Create the ShaderBrush Classes

We need wrapper classes to use RuntimeShader with Compose. These handle passing uniforms and setting up the shader:

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class RuntimeShaderBrush(private val shader: RuntimeShader) : ShaderBrush() {
    override fun createShader(size: Size): Shader {
        shader.setFloatUniform("resolution", size.width, size.height)
        return shader
    }
}

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class RuntimeShaderBrushWithImage(
    private val shader: RuntimeShader,
    private val bitmap: Bitmap,
    private val mousePosition: Offset
) : ShaderBrush() {
    override fun createShader(size: Size): Shader {
        shader.setFloatUniform("resolution", size.width, size.height)
        
        // Set mouse position (use center if not set yet)
        val mouseX = if (mousePosition.isUnspecified) size.width / 2f else mousePosition.x
        val mouseY = if (mousePosition.isUnspecified) size.height / 2f else mousePosition.y
        shader.setFloatUniform("mouse", mouseX, mouseY)
        
        // Create bitmap shader and scale it to fit the canvas
        val bitmapShader = BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP)
        
        // Calculate scale to fit the bitmap to the canvas size
        val scaleX = size.width / bitmap.width
        val scaleY = size.height / bitmap.height
        
        val matrix = Matrix()
        matrix.setScale(scaleX, scaleY)
        bitmapShader.setLocalMatrix(matrix)
        
        shader.setInputShader("image", bitmapShader)
        return shader
    }
}

Key Points:

  • setFloatUniform(): Passes values from Kotlin to shader
  • setInputShader(): Passes the image/texture to sample from
  • Matrix.setScale(): Scales the bitmap to fit the canvas

Step 3: Create the Composable

Now let's build the Compose UI with touch interaction:

@Composable
fun LiquidGlassEffect() {
    val resources = LocalResources.current
    
    // Track pointer position for the glass lens
    var pointerPosition by remember { mutableStateOf(Offset.Unspecified) }
    
    // Load your image
    val bitmap = remember {
        BitmapFactory.decodeResource(resources, R.drawable.city)
    }
    
    // Calculate aspect ratio to display image correctly
    val aspectRatio = remember(bitmap) {
        bitmap.width.toFloat() / bitmap.height.toFloat()
    }
    
    val brush = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        // Create the RuntimeShader
        val shader = remember { RuntimeShader(LIQUID_GLASS_SHADER) }

        remember(shader, pointerPosition) {
            RuntimeShaderBrushWithImage(shader, bitmap, pointerPosition)
        }
    } else {
        // Fallback for Android 12 and below
        // Provide your own fallback UI here (simple gradient, blur effect, etc.)
        null
    }

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .aspectRatio(aspectRatio)
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(brush)
                .pointerInput(Unit) {
                    detectTapGestures { offset ->
                        pointerPosition = offset
                    }
                }
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { offset ->
                            pointerPosition = offset
                        },
                        onDrag = { change, _ ->
                            pointerPosition = change.position
                        }
                    )
                }
        )
    }
}

Don't forget to place your image in res/drawable/ (e.g., city.jpeg) or use any other drawable resource.

Take the online course and join the exclusive community

Attendee avatarAttendee avatarAttendee avatarAttendee avatarAttendee avatarAttendee avatar

Master Jetpack Compose and learn how to work efficiently with it. Enjoy the perfect mix of theory and exercises with the best trainer. Join a community of 500+ devs.

★★★★★
Jorge Castillo
Created and delivered by Jorge Castillo, Google Developer Expert for Android and Kotlin
Trusted by top companies

Customization Options

You can easily tweak the shader parameters to customize the effect:

1. Lens Size

Adjust the LENS_SIZE_MULTIPLIER constant:

const float LENS_SIZE_MULTIPLIER = 200000.0; // Default

// Smaller lens (more compact)
const float LENS_SIZE_MULTIPLIER = 300000.0;

// Larger lens (wider coverage)
const float LENS_SIZE_MULTIPLIER = 100000.0;

2. Chromatic Aberration Intensity

Control the rainbow fringing effect:

const float CHROMATIC_ABERRATION = 0.02; // Default: subtle effect

// More intense color separation
const float CHROMATIC_ABERRATION = 0.05;

// Minimal color separation
const float CHROMATIC_ABERRATION = 0.01;

// No chromatic aberration
const float CHROMATIC_ABERRATION = 0.0;

3. Magnification Power

Change how much the lens magnifies:

const float REFRACTIVE_INDEX = 1.5; // Default: moderate magnification

// Stronger magnification (like a magnifying glass)
const float REFRACTIVE_INDEX = 2.0;

// Mild magnification
const float REFRACTIVE_INDEX = 1.2;

// No magnification (just blur and chromatic aberration)
const float REFRACTIVE_INDEX = 1.0;

4. Blur Quality

Adjust the BLUR_SAMPLES for performance vs quality trade-off:

const float BLUR_SAMPLES = 4.0; // Default: 9x9 grid (81 samples)

// Higher quality blur (slower, 13x13 = 169 samples)
const float BLUR_SAMPLES = 6.0;

// Lower quality blur (faster, 5x5 = 25 samples)
const float BLUR_SAMPLES = 2.0;

// No blur (fastest, just distortion and chromatic aberration)
// Comment out the entire blur loop

5. Lens Shape

Change the shape exponent to modify the lens appearance:

// Current: rounded rectangle
float distanceField = pow(abs(distanceX), 8.0) + pow(abs(distanceFromMouse.y), 8.0);

// Perfect circle
float distanceField = pow(abs(distanceX), 2.0) + pow(abs(distanceFromMouse.y), 2.0);

// Very square with minimal rounding
float distanceField = pow(abs(distanceX), 16.0) + pow(abs(distanceFromMouse.y), 16.0);

Quick Reference Table

ParameterDefaultSmaller/LessLarger/More
Lens Size200000300000+100000-
Chromatic Aberration0.020.01-0.05+
Refractive Index1.51.0-1.31.7-2.5
Blur Quality4.02.06.0+
Shape Roundness8.02.0 (circle)16.0+ (square)

Performance Considerations

  • GPU-Accelerated: Shaders run on the GPU, making them highly performant
  • Recomposition: Use remember to avoid recreating the shader on every recomposition
  • Bitmap Size: Large images may impact performance; consider downscaling
  • Blur Kernel: The 9x9 sampling is a balance between quality and performance

Platform Support & Requirements

Minimum Requirements

  • RuntimeShader: Requires API 33+ (Android 13) - this is non-negotiable
  • App minSdk: Can be API 21+ if you provide fallback UI for devices < API 33
  • For the shader to work: Device must be running Android 13+

API Level Support

Android VersionAPI LevelRuntimeShader Support
Android 13+33+✅ Full Support
Android 12 and below≤32❌ Not Available - Fallback Required

Testing

  • Test on Real Devices: Emulators may not accurately represent shader performance
  • Monitor Performance: Use Android Profiler to check GPU usage, run Macrobenchmark tests and monitor for FrameTiming metrics to monitor drawing performance.
  • Test Fallbacks: Verify your fallback UI works well on API < 33

Performance Issues

If you hit performance issues you can try changing blur kernel size for better performance:

// Instead of 9x9 sampling (81 samples)
const float BLUR_SAMPLES = 4.0;
    
// Use 5x5 for better performance (25 samples)
const float BLUR_SAMPLES = 2.0;

Key Takeaways

~40% of devices support RuntimeShader today (as of 2024), and this percentage increases as users upgrade

What about using GLSL/OpenGL ES for older versions?

A: While technically possible, it's not recommended. OpenGL ES requires GLSurfaceView which doesn't integrate well with Jetpack Compose's declarative model, adds significant complexity, and creates maintenance challenges.

Is it worth using if it only works on Android 13+?

A: Yes, if premium visual effects enhance your app's experience. Modern Android development often means embracing cutting-edge features on newer OS versions while providing graceful fallbacks. As of 2024, ~40% of devices run Android 13+, and this percentage grows monthly.

Resources

Follow Me

If you enjoyed this tutorial and want to see more content about Android development, shaders, and Jetpack Compose:

Take the online course and join the exclusive community

Attendee avatarAttendee avatarAttendee avatarAttendee avatarAttendee avatarAttendee avatar

Master Jetpack Compose and learn how to work efficiently with it. Enjoy the perfect mix of theory and exercises with the best trainer. Join a community of 500+ devs.

★★★★★
Jorge Castillo
Created and delivered by Jorge Castillo, Google Developer Expert for Android and Kotlin
Trusted by top companies

Related Articles