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:
- 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.0sharpens the edge transition
- 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
- 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 shadersetInputShader(): Passes the image/texture to sample fromMatrix.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
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.

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 loop5. 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
| Parameter | Default | Smaller/Less | Larger/More |
|---|---|---|---|
| Lens Size | 200000 | 300000+ | 100000- |
| Chromatic Aberration | 0.02 | 0.01- | 0.05+ |
| Refractive Index | 1.5 | 1.0-1.3 | 1.7-2.5 |
| Blur Quality | 4.0 | 2.0 | 6.0+ |
| Shape Roundness | 8.0 | 2.0 (circle) | 16.0+ (square) |
Performance Considerations
- GPU-Accelerated: Shaders run on the GPU, making them highly performant
- Recomposition: Use
rememberto 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 Version | API Level | RuntimeShader 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
FrameTimingmetrics 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
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.

Related Articles
Recomposition Scopes in Jetpack Compose
Learn how recomposition scopes make Jetpack Compose smart, efficient, and fast with working examples.
RenderNode in Jetpack Compose
Learn how RenderNodes work in Jetpack Compose and Android Views. Understand drawing commands, GPU optimization, and performance benefits.