
Liquid glass visual effects and runtime shader system for UI, enabling reusable fragment shaders, animated ripples and continuous rounded-capsule shapes with unified render-effect API.
Gaze released a group of artifacts, including:
glassy: Liquid Glass effect library for Compose Multiplatform.
capsule: G2 continuous rounded rectangles for Compose Multiplatform.
This repository hosts the glassy code.
Provides cross-platform RuntimeShader & RenderEffect implementation.
With gaze-glassy-core, you can write shader code once and have it run on all supported platforms, including Android, iOS, Desktop, and Web. This is achieved by leveraging platform-specific APIs under the hood while providing a unified interface for developers.
The Liquid Glass effect library.
This module is the direct downstream project of Kyant's Backdrop (a.k.a. AndroidLiquidGlass). It migrates the original Jetpack Compose implementation to Compose Multiplatform.
Settings management for the liquid effect.
:liquid:settings:core: Core logic and data models for settings.:liquid:settings:client: Client-side implementation for applying settings.:liquid:settings:configurator: UI/Tooling for configuring the effect parameters.Example usage of RuntimeShader and RenderEffect:
private const val RippleShaderString = """
uniform shader content;
uniform float2 iResolution;
uniform float rippleData[40];
uniform int rippleCount;
uniform float amplitude;
uniform float frequency;
uniform float decay;
uniform float speed;
float2 calculateRippleOffset(float2 position, float2 origin, float time) {
float distance = length(position - origin);
float delay = distance / speed;
float adjustedTime = max(0.0, time - delay);
float rippleAmount = amplitude * sin(frequency * adjustedTime) * exp(-decay * adjustedTime);
return rippleAmount * normalize(position - origin);
}
float calculateBrightness(float2 position, float2 origin, float time) {
float distance = length(position - origin);
float delay = distance / speed;
float adjustedTime = max(0.0, time - delay);
float rippleAmount = amplitude * sin(frequency * adjustedTime) * exp(-decay * adjustedTime);
return 0.3 * (rippleAmount / amplitude) * exp(-decay * adjustedTime);
}
half4 main(float2 fragCoord) {
float2 position = fragCoord;
float2 totalOffset = float2(0.0, 0.0);
float totalBrightness = 0.0;
if (rippleCount > 0) {
float2 origin0 = float2(rippleData[0], rippleData[1]);
totalOffset += calculateRippleOffset(position, origin0, rippleData[2]);
totalBrightness += calculateBrightness(position, origin0, rippleData[2]);
}
if (rippleCount > 1) {
float2 origin1 = float2(rippleData[4], rippleData[5]);
totalOffset += calculateRippleOffset(position, origin1, rippleData[6]);
totalBrightness += calculateBrightness(position, origin1, rippleData[6]);
}
if (rippleCount > 2) {
float2 origin2 = float2(rippleData[8], rippleData[9]);
totalOffset += calculateRippleOffset(position, origin2, rippleData[10]);
totalBrightness += calculateBrightness(position, origin2, rippleData[10]);
}
if (rippleCount > 3) {
float2 origin3 = float2(rippleData[12], rippleData[13]);
totalOffset += calculateRippleOffset(position, origin3, rippleData[14]);
totalBrightness += calculateBrightness(position, origin3, rippleData[14]);
}
if (rippleCount > 4) {
float2 origin4 = float2(rippleData[16], rippleData[17]);
totalOffset += calculateRippleOffset(position, origin4, rippleData[18]);
totalBrightness += calculateBrightness(position, origin4, rippleData[18]);
}
if (rippleCount > 5) {
float2 origin5 = float2(rippleData[20], rippleData[21]);
totalOffset += calculateRippleOffset(position, origin5, rippleData[22]);
totalBrightness += calculateBrightness(position, origin5, rippleData[22]);
}
if (rippleCount > 6) {
float2 origin6 = float2(rippleData[24], rippleData[25]);
totalOffset += calculateRippleOffset(position, origin6, rippleData[26]);
totalBrightness += calculateBrightness(position, origin6, rippleData[26]);
}
if (rippleCount > 7) {
float2 origin7 = float2(rippleData[28], rippleData[29]);
totalOffset += calculateRippleOffset(position, origin7, rippleData[30]);
totalBrightness += calculateBrightness(position, origin7, rippleData[30]);
}
if (rippleCount > 8) {
float2 origin8 = float2(rippleData[32], rippleData[33]);
totalOffset += calculateRippleOffset(position, origin8, rippleData[34]);
totalBrightness += calculateBrightness(position, origin8, rippleData[34]);
}
if (rippleCount > 9) {
float2 origin9 = float2(rippleData[36], rippleData[37]);
totalOffset += calculateRippleOffset(position, origin9, rippleData[38]);
totalBrightness += calculateBrightness(position, origin9, rippleData[38]);
}
float2 newPosition = position + totalOffset;
half4 color = content.eval(newPosition);
color.rgb += totalBrightness * color.a;
return color;
}
"""
private data class RippleState(
val position: Offset,
val animatable: Animatable<Float, *>
)
private class RippleIndicationNode(
private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
override val shouldAutoInvalidate: Boolean = false
private val activeRipples = mutableListOf<RippleState>()
private val amplitude: Float = 10f
private val frequency: Float = 8f
private val decay: Float = 1.5f
private val speed: Float = 800f
private val maxRipples: Int = 10
private var contentLayer: GraphicsLayer? = null
private var runtimeShader = if (PlatformVersion.supportsRuntimeShader()) {
try {
val shader = createRuntimeShader(RippleShaderString)
shader
} catch (e: Exception) {
null
}
} else {
null
}
private var currentSize: androidx.compose.ui.geometry.Size? = null
private fun calculateDuration(componentSize: androidx.compose.ui.geometry.Size, pressPosition: Offset): Int {
val corners = listOf(
Offset(0f, 0f),
Offset(componentSize.width, 0f),
Offset(0f, componentSize.height),
Offset(componentSize.width, componentSize.height)
)
val maxDistance = corners.maxOf { corner ->
kotlin.math.sqrt(
(corner.x - pressPosition.x) * (corner.x - pressPosition.x) +
(corner.y - pressPosition.y) * (corner.y - pressPosition.y)
)
}
val propagationTime = (maxDistance / speed) * 1000
val decayTime = (3 / decay) * 1000
return (propagationTime + decayTime).toInt().coerceAtLeast(800)
}
private suspend fun animateRipple(pressPosition: Offset, componentSize: androidx.compose.ui.geometry.Size) {
val duration = calculateDuration(componentSize, pressPosition)
val animatable = Animatable(0f)
val ripple = RippleState(pressPosition, animatable)
if (activeRipples.size >= maxRipples) {
activeRipples.removeAt(0)
}
activeRipples.add(ripple)
animatable.animateTo(
targetValue = duration / 1000f,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
) {
invalidateDraw()
}
activeRipples.remove(ripple)
invalidateDraw()
}
override fun onAttach() {
val graphicsContext = requireGraphicsContext()
contentLayer = graphicsContext.createGraphicsLayer()
coroutineScope.launch {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
currentSize?.let { size ->
launch {
animateRipple(interaction.pressPosition, size)
}
}
}
is PressInteraction.Release -> {}
is PressInteraction.Cancel -> {}
}
}
}
}
override fun onDetach() {
val graphicsContext = requireGraphicsContext()
contentLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
contentLayer = null
}
}
override fun ContentDrawScope.draw() {
currentSize = size
val layer = contentLayer
val shader = runtimeShader
if (layer == null || shader == null || !PlatformVersion.supportsRuntimeShader()) {
drawContent()
return
}
layer.record(size = size.toIntSize()) {
this@draw.drawContent()
}
if (activeRipples.isNotEmpty()) {
val rippleData = FloatArray(40)
activeRipples.take(10).forEachIndexed { index, ripple ->
val baseIndex = index * 4
rippleData[baseIndex] = ripple.position.x
rippleData[baseIndex + 1] = ripple.position.y
rippleData[baseIndex + 2] = ripple.animatable.value
}
shader.apply {
setFloatUniform("iResolution", size.width, size.height)
setIntUniform("rippleCount", activeRipples.size.coerceAtMost(10))
setFloatUniform("amplitude", amplitude)
setFloatUniform("frequency", frequency)
setFloatUniform("decay", decay)
setFloatUniform("speed", speed)
setFloatUniform("rippleData", rippleData)
}
val effect = createRuntimeShaderEffect(shader, "content")
if (effect != null) {
val composeEffect = convertToComposeRenderEffect(effect)
if (composeEffect != null) {
layer.renderEffect = composeEffect
}
}
} else {
layer.renderEffect = null
}
drawLayer(layer)
}
}
object RippleIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
return RippleIndicationNode(interactionSource)
}
override fun hashCode(): Int = -1
override fun equals(other: Any?) = other === this
}Gaze released a group of artifacts, including:
glassy: Liquid Glass effect library for Compose Multiplatform.
capsule: G2 continuous rounded rectangles for Compose Multiplatform.
This repository hosts the glassy code.
Provides cross-platform RuntimeShader & RenderEffect implementation.
With gaze-glassy-core, you can write shader code once and have it run on all supported platforms, including Android, iOS, Desktop, and Web. This is achieved by leveraging platform-specific APIs under the hood while providing a unified interface for developers.
The Liquid Glass effect library.
This module is the direct downstream project of Kyant's Backdrop (a.k.a. AndroidLiquidGlass). It migrates the original Jetpack Compose implementation to Compose Multiplatform.
Settings management for the liquid effect.
:liquid:settings:core: Core logic and data models for settings.:liquid:settings:client: Client-side implementation for applying settings.:liquid:settings:configurator: UI/Tooling for configuring the effect parameters.Example usage of RuntimeShader and RenderEffect:
private const val RippleShaderString = """
uniform shader content;
uniform float2 iResolution;
uniform float rippleData[40];
uniform int rippleCount;
uniform float amplitude;
uniform float frequency;
uniform float decay;
uniform float speed;
float2 calculateRippleOffset(float2 position, float2 origin, float time) {
float distance = length(position - origin);
float delay = distance / speed;
float adjustedTime = max(0.0, time - delay);
float rippleAmount = amplitude * sin(frequency * adjustedTime) * exp(-decay * adjustedTime);
return rippleAmount * normalize(position - origin);
}
float calculateBrightness(float2 position, float2 origin, float time) {
float distance = length(position - origin);
float delay = distance / speed;
float adjustedTime = max(0.0, time - delay);
float rippleAmount = amplitude * sin(frequency * adjustedTime) * exp(-decay * adjustedTime);
return 0.3 * (rippleAmount / amplitude) * exp(-decay * adjustedTime);
}
half4 main(float2 fragCoord) {
float2 position = fragCoord;
float2 totalOffset = float2(0.0, 0.0);
float totalBrightness = 0.0;
if (rippleCount > 0) {
float2 origin0 = float2(rippleData[0], rippleData[1]);
totalOffset += calculateRippleOffset(position, origin0, rippleData[2]);
totalBrightness += calculateBrightness(position, origin0, rippleData[2]);
}
if (rippleCount > 1) {
float2 origin1 = float2(rippleData[4], rippleData[5]);
totalOffset += calculateRippleOffset(position, origin1, rippleData[6]);
totalBrightness += calculateBrightness(position, origin1, rippleData[6]);
}
if (rippleCount > 2) {
float2 origin2 = float2(rippleData[8], rippleData[9]);
totalOffset += calculateRippleOffset(position, origin2, rippleData[10]);
totalBrightness += calculateBrightness(position, origin2, rippleData[10]);
}
if (rippleCount > 3) {
float2 origin3 = float2(rippleData[12], rippleData[13]);
totalOffset += calculateRippleOffset(position, origin3, rippleData[14]);
totalBrightness += calculateBrightness(position, origin3, rippleData[14]);
}
if (rippleCount > 4) {
float2 origin4 = float2(rippleData[16], rippleData[17]);
totalOffset += calculateRippleOffset(position, origin4, rippleData[18]);
totalBrightness += calculateBrightness(position, origin4, rippleData[18]);
}
if (rippleCount > 5) {
float2 origin5 = float2(rippleData[20], rippleData[21]);
totalOffset += calculateRippleOffset(position, origin5, rippleData[22]);
totalBrightness += calculateBrightness(position, origin5, rippleData[22]);
}
if (rippleCount > 6) {
float2 origin6 = float2(rippleData[24], rippleData[25]);
totalOffset += calculateRippleOffset(position, origin6, rippleData[26]);
totalBrightness += calculateBrightness(position, origin6, rippleData[26]);
}
if (rippleCount > 7) {
float2 origin7 = float2(rippleData[28], rippleData[29]);
totalOffset += calculateRippleOffset(position, origin7, rippleData[30]);
totalBrightness += calculateBrightness(position, origin7, rippleData[30]);
}
if (rippleCount > 8) {
float2 origin8 = float2(rippleData[32], rippleData[33]);
totalOffset += calculateRippleOffset(position, origin8, rippleData[34]);
totalBrightness += calculateBrightness(position, origin8, rippleData[34]);
}
if (rippleCount > 9) {
float2 origin9 = float2(rippleData[36], rippleData[37]);
totalOffset += calculateRippleOffset(position, origin9, rippleData[38]);
totalBrightness += calculateBrightness(position, origin9, rippleData[38]);
}
float2 newPosition = position + totalOffset;
half4 color = content.eval(newPosition);
color.rgb += totalBrightness * color.a;
return color;
}
"""
private data class RippleState(
val position: Offset,
val animatable: Animatable<Float, *>
)
private class RippleIndicationNode(
private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
override val shouldAutoInvalidate: Boolean = false
private val activeRipples = mutableListOf<RippleState>()
private val amplitude: Float = 10f
private val frequency: Float = 8f
private val decay: Float = 1.5f
private val speed: Float = 800f
private val maxRipples: Int = 10
private var contentLayer: GraphicsLayer? = null
private var runtimeShader = if (PlatformVersion.supportsRuntimeShader()) {
try {
val shader = createRuntimeShader(RippleShaderString)
shader
} catch (e: Exception) {
null
}
} else {
null
}
private var currentSize: androidx.compose.ui.geometry.Size? = null
private fun calculateDuration(componentSize: androidx.compose.ui.geometry.Size, pressPosition: Offset): Int {
val corners = listOf(
Offset(0f, 0f),
Offset(componentSize.width, 0f),
Offset(0f, componentSize.height),
Offset(componentSize.width, componentSize.height)
)
val maxDistance = corners.maxOf { corner ->
kotlin.math.sqrt(
(corner.x - pressPosition.x) * (corner.x - pressPosition.x) +
(corner.y - pressPosition.y) * (corner.y - pressPosition.y)
)
}
val propagationTime = (maxDistance / speed) * 1000
val decayTime = (3 / decay) * 1000
return (propagationTime + decayTime).toInt().coerceAtLeast(800)
}
private suspend fun animateRipple(pressPosition: Offset, componentSize: androidx.compose.ui.geometry.Size) {
val duration = calculateDuration(componentSize, pressPosition)
val animatable = Animatable(0f)
val ripple = RippleState(pressPosition, animatable)
if (activeRipples.size >= maxRipples) {
activeRipples.removeAt(0)
}
activeRipples.add(ripple)
animatable.animateTo(
targetValue = duration / 1000f,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
) {
invalidateDraw()
}
activeRipples.remove(ripple)
invalidateDraw()
}
override fun onAttach() {
val graphicsContext = requireGraphicsContext()
contentLayer = graphicsContext.createGraphicsLayer()
coroutineScope.launch {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
currentSize?.let { size ->
launch {
animateRipple(interaction.pressPosition, size)
}
}
}
is PressInteraction.Release -> {}
is PressInteraction.Cancel -> {}
}
}
}
}
override fun onDetach() {
val graphicsContext = requireGraphicsContext()
contentLayer?.let { layer ->
graphicsContext.releaseGraphicsLayer(layer)
contentLayer = null
}
}
override fun ContentDrawScope.draw() {
currentSize = size
val layer = contentLayer
val shader = runtimeShader
if (layer == null || shader == null || !PlatformVersion.supportsRuntimeShader()) {
drawContent()
return
}
layer.record(size = size.toIntSize()) {
this@draw.drawContent()
}
if (activeRipples.isNotEmpty()) {
val rippleData = FloatArray(40)
activeRipples.take(10).forEachIndexed { index, ripple ->
val baseIndex = index * 4
rippleData[baseIndex] = ripple.position.x
rippleData[baseIndex + 1] = ripple.position.y
rippleData[baseIndex + 2] = ripple.animatable.value
}
shader.apply {
setFloatUniform("iResolution", size.width, size.height)
setIntUniform("rippleCount", activeRipples.size.coerceAtMost(10))
setFloatUniform("amplitude", amplitude)
setFloatUniform("frequency", frequency)
setFloatUniform("decay", decay)
setFloatUniform("speed", speed)
setFloatUniform("rippleData", rippleData)
}
val effect = createRuntimeShaderEffect(shader, "content")
if (effect != null) {
val composeEffect = convertToComposeRenderEffect(effect)
if (composeEffect != null) {
layer.renderEffect = composeEffect
}
}
} else {
layer.renderEffect = null
}
drawLayer(layer)
}
}
object RippleIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
return RippleIndicationNode(interactionSource)
}
override fun hashCode(): Int = -1
override fun equals(other: Any?) = other === this
}