Skip to content

Instantly share code, notes, and snippets.

@Mikkareem
Last active April 6, 2026 20:50
Show Gist options
  • Select an option

  • Save Mikkareem/1a80129f67bbca3e1a2837e29116801f to your computer and use it in GitHub Desktop.

Select an option

Save Mikkareem/1a80129f67bbca3e1a2837e29116801f to your computer and use it in GitHub Desktop.

Physics Bubble Screen in Compose Multiplatform

Using RenderEffect

Targets: Android / IOS / Desktop / JVM

All Demos are available in Comments

Thanks to Kyriakos-Georgiopoulos

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.hypot
import kotlin.math.roundToInt
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class AppRuntimeShader
expect fun createAppRuntimeShader(
shaderCode: String
): AppRuntimeShader
expect fun createAppRuntimeShaderRenderEffect(
shader: AppRuntimeShader,
name: String
): RenderEffect
expect fun AppRuntimeShader.setFloatUniformValue(name: String, value: Float)
expect fun AppRuntimeShader.setFloatUniformValue(name: String, value1: Float, value2: Float)
expect fun isAvailable(): Boolean
@Composable
expect fun StatusBarChangeIfNeeded(isDarkTheme: Boolean)
/**
* # PhysicsBubbleScreen
*
* A physics-driven soap bubble demo built with Jetpack Compose and AGSL.
*
* ## What it does
*
* Renders an interactive soap bubble that the user can drag vertically across the screen.
* The bubble refracts the content behind it, displays thin-film interference colors
* (the rainbow swirls you see on real soap bubbles), and deforms based on movement velocity.
* Tapping anywhere pops the bubble with a fade-out animation.
*
* ## Physics involved
*
* ### Thin-film interference (AGSL shader)
*
* Real soap bubbles get their colors from light bouncing between the two surfaces
* of a thin water film. When a light ray hits the bubble:
*
* 1. Part of it reflects off the outer surface.
* 2. Part enters the film, travels through it, and reflects off the inner surface.
* 3. These two reflected rays combine. Depending on the film thickness and the
* wavelength of light, they either reinforce each other (constructive interference,
* bright color) or cancel out (destructive interference, dark band).
*
* The math: the optical path difference is `Δ = 2 * n * d * cos(θt)`, where `n` is
* the refractive index (1.33 for soapy water), `d` is the film thickness, and `θt`
* is the refracted angle inside the film (from Snell's law). We evaluate this for
* three wavelengths (R=650nm, G=532nm, B=450nm) and compute a cosine oscillation
* for each channel. The result is the characteristic color bands.
*
* ### Fresnel reflectance (AGSL shader)
*
* How much light reflects vs passes through depends on the viewing angle. At normal
* incidence (looking straight at the bubble), reflectance is low (~2%). At grazing
* angles (the edge/rim), reflectance approaches 100% — that's why bubble edges
* look brighter and whiter. We approximate this with the Schlick formula:
* `R = R0 + (1 - R0) * (1 - cos(θ))^5`.
*
* ### Kinematic spring deformation (Compose frame loop)
*
* When the bubble moves, it squashes along the direction of motion and stretches
* perpendicular to it — like a real bubble resisting air drag. This uses a
* volume-preserving transform: if you stretch by factor `s` along one axis,
* you squash by `1/√s` on the other to keep the area constant.
*
* The deformation amount is driven by a manually-stepped Euler spring that tracks
* the drag velocity. This avoids creating a new coroutine every frame and gives
* smooth, jelly-like oscillation.
*
* ### Chromatic aberration (AGSL shader)
*
* Light bends differently depending on wavelength. We sample the background at
* three slightly different offsets for R, G, and B channels, creating a subtle
* color fringe around refracted content — just like a real lens or bubble.
*
* ## API level
*
* The AGSL shader requires API 33+ (Tiramisu). On older devices, a simple radial
* gradient fallback is drawn instead.
*/
/**
* Layout and animation constants for the bubble.
*
* Ratios are relative to screen height. The bubble lives on a vertical
* track between [BOTTOM_ORB_RATIO] (rest position) and [TOP_ORB_RATIO] (pulled up).
*/
private object BubbleConfig {
/** Y position of the bubble at rest, as a fraction of screen height. */
const val BOTTOM_ORB_RATIO = 0.92f
/** Y position of the bubble when fully dragged up. */
const val TOP_ORB_RATIO = 0.28f
/** Maximum bubble radius (at rest / bottom position). */
val MAX_ORB_RADIUS = 250.dp
/** Minimum bubble radius (when fully pulled to the top). */
val MIN_ORB_RADIUS = 85.dp
/** Y position of the detail text block at the bottom state. */
const val TEXT_Y_BOTTOM_RATIO = 0.48f
/** Y position of the detail text block at the top state. */
const val TEXT_Y_TOP_RATIO = 0.42f
/**
* Distance threshold (px) from the top position at which the bubble
* "unlocks" and allows free XY dragging instead of vertical-only.
*/
const val SNAP_UNLOCK_THRESHOLD = 10f
/** How far below the rest position the bubble can be dragged (overshoot). */
const val DRAG_OVERSHOOT_RATIO = 0.05f
/** Multiplier converting drag velocity into deformation magnitude. */
const val DEFORMATION_FACTOR = 0.015f
/** Maximum deformation clamp to prevent extreme stretching. */
const val DEFORMATION_CLAMP = 0.6f
/** Low-pass filter factor for smoothing velocity between frames. */
const val VELOCITY_SMOOTHING = 0.15f
/** Duration (ms) of the circular-reveal theme transition. */
const val THEME_REVEAL_DURATION = 1100
/** Duration (ms) of the bubble pop fade-out. */
const val POP_DURATION = 150
/** Delay (ms) before the bubble respawns after popping. */
const val POP_DELAY = 2000L
/** Duration (ms) of the text color crossfade on theme change. */
const val TEXT_ANIM_DURATION = 700
}
/** Color palette for both light and dark themes. */
private object BubbleColors {
val LIGHT_CENTER = Color(0xFFFFFFFF)
val LIGHT_MID1 = Color(0xFFFBF8F6)
val LIGHT_MID2 = Color(0xFFF5EFEE)
val LIGHT_EDGE = Color(0xFFEEEAE8)
val DARK_CENTER = Color(0xFF2A2D34)
val DARK_MID = Color(0xFF16171B)
val DARK_EDGE = Color(0xFF0A0B0D)
val LIGHT_MAIN_TEXT = Color(0xFF4A403A)
val DARK_MAIN_TEXT = Color(0xFFE5E5EA)
val LIGHT_TITLE = Color(0xFF1F1A17)
val DARK_TITLE = Color(0xFFF5F5F7)
val LIGHT_SUBTITLE = Color(0xFF8A807A)
val DARK_SUBTITLE = Color(0xFFA1A1A6)
}
/** Spring config for snapping back to rest or top position (locked vertical drag). */
private val SnapBackSpring = spring<Offset>(
dampingRatio = 0.65f,
stiffness = Spring.StiffnessLow
)
/** Spring config for snapping when unlocked (free XY drag). More bouncy. */
private val UnlockedSnapSpring = spring<Offset>(
dampingRatio = 0.45f,
stiffness = Spring.StiffnessLow
)
/**
* Holds the mutable animation state for the bubble.
*
* Marked [Stable] so Compose can skip recomposition when the reference hasn't changed.
* All animation values are [Animatable], which means they're updated outside of
* recomposition — the shader and draw calls read them directly each frame.
*
* @param screenHeightPx Total screen height in pixels.
* @param orbRadiusMaxPx Maximum bubble radius in pixels.
* @param orbRadiusMinPx Minimum bubble radius in pixels.
* @param centerX Horizontal center of the screen in pixels.
*/
@Stable
class PhysicsBubbleState(
private val screenHeightPx: Float,
orbRadiusMaxPx: Float,
orbRadiusMinPx: Float,
val centerX: Float,
) {
/** Y center of the bubble at rest (bottom). */
val bottomOrbCenterY = screenHeightPx * BubbleConfig.BOTTOM_ORB_RATIO
/** Y center of the bubble when fully pulled up (top). */
val topOrbCenterY = screenHeightPx * BubbleConfig.TOP_ORB_RATIO
/** Midpoint between top and bottom — used to decide snap direction on release. */
val midPoint = (bottomOrbCenterY + topOrbCenterY) / 2f
/** Maximum Y the bubble can be dragged to (slightly below rest for overshoot). */
val maxDragY = bottomOrbCenterY + (screenHeightPx * BubbleConfig.DRAG_OVERSHOOT_RATIO)
private val orbRadiusMax = orbRadiusMaxPx
private val orbRadiusMin = orbRadiusMinPx
private val orbRange = bottomOrbCenterY - topOrbCenterY
private val textYBottom = screenHeightPx * BubbleConfig.TEXT_Y_BOTTOM_RATIO
private val textYTop = screenHeightPx * BubbleConfig.TEXT_Y_TOP_RATIO
/** Current bubble position. Animated via spring physics on drag release. */
val bubblePos = Animatable(Offset(centerX, bottomOrbCenterY), Offset.VectorConverter)
/**
* Current squash/stretch deformation.
*
* X and Y represent how much the bubble is deformed along each axis.
* Driven by a manual Euler spring in [DeformationFrameLoop].
*/
val deformationAnim = Animatable(Offset.Zero, Offset.VectorConverter)
/** Pop animation progress: 0 = normal, 1 = fully popped/invisible. */
val popAnim = Animatable(0f)
/** Circular reveal progress for the theme transition: 0 = start, 1 = complete. */
val themeRevealProgress = Animatable(1f)
/** Elapsed time in seconds, fed to the AGSL shader for animated noise. */
val shaderTime = floatArrayOf(0f)
/**
* Normalized drag progress from bottom (0) to top (1).
* Used to interpolate bubble radius, text position, and opacity.
*/
val progress: Float
get() = ((bottomOrbCenterY - bubblePos.value.y) / orbRange).coerceIn(0f, 1f)
/** Bubble radius interpolated between max (bottom) and min (top). */
val currentOrbRadius: Float
get() = androidx.compose.ui.util.lerp(orbRadiusMax, orbRadiusMin, progress)
/** Y offset of the detail text block, interpolated with drag progress. */
val textYOffsetPx: Float
get() = androidx.compose.ui.util.lerp(textYBottom, textYTop, progress)
/** Returns true if the bubble is at (or very near) the top snap position. */
fun isAtTop(): Boolean =
bubblePos.value.y <= topOrbCenterY + BubbleConfig.SNAP_UNLOCK_THRESHOLD
}
/**
* Creates and remembers a [PhysicsBubbleState] scoped to the current screen dimensions.
* Re-creates the state if screen size changes (e.g. rotation).
*/
@Composable
private fun rememberBubbleState(
screenWidthPx: Float,
screenHeightPx: Float,
): PhysicsBubbleState {
val density = LocalDensity.current
val maxRadiusPx = with(density) { BubbleConfig.MAX_ORB_RADIUS.toPx() }
val minRadiusPx = with(density) { BubbleConfig.MIN_ORB_RADIUS.toPx() }
return remember(screenWidthPx, screenHeightPx) {
PhysicsBubbleState(
screenHeightPx = screenHeightPx,
orbRadiusMaxPx = maxRadiusPx,
orbRadiusMinPx = minRadiusPx,
centerX = screenWidthPx / 2f,
)
}
}
/**
* Entry point. Measures the screen and sets up the bubble state.
*
* Uses [BoxWithConstraints] to get pixel dimensions before creating
* position-dependent state like snap points and text offsets.
*/
@Composable
fun PhysicsBubbleScreen() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val screenWidthPx = constraints.maxWidth.toFloat()
val screenHeightPx = constraints.maxHeight.toFloat()
val state = rememberBubbleState(screenWidthPx, screenHeightPx)
PhysicsBubbleContent(state, screenWidthPx, screenHeightPx)
}
}
/**
* Main content layout.
*
* Manages the theme state, background drawing, text layers, and composes the
* shader layer that renders the bubble. The background uses a circular-reveal
* clip animation that expands from the toggle button position.
*/
@Composable
private fun PhysicsBubbleContent(
state: PhysicsBubbleState,
screenWidthPx: Float,
screenHeightPx: Float,
) {
val scope = rememberCoroutineScope()
var isDarkTheme by remember { mutableStateOf(false) }
var previousIsDark by remember { mutableStateOf(false) }
StatusBarChangeIfNeeded(isDarkTheme)
val lightBrush = remember(screenWidthPx, screenHeightPx) {
createRadialBrush(
screenWidthPx, screenHeightPx,
BubbleColors.LIGHT_CENTER, BubbleColors.LIGHT_MID1,
BubbleColors.LIGHT_MID2, BubbleColors.LIGHT_EDGE
)
}
val darkBrush = remember(screenWidthPx, screenHeightPx) {
createRadialBrush(
screenWidthPx, screenHeightPx,
BubbleColors.DARK_CENTER, BubbleColors.DARK_MID,
BubbleColors.DARK_MID, BubbleColors.DARK_EDGE
)
}
val textTween = remember {
tween<Color>(BubbleConfig.TEXT_ANIM_DURATION, easing = FastOutLinearInEasing)
}
val mainTextColor by animateColorAsState(
if (isDarkTheme) BubbleColors.DARK_MAIN_TEXT else BubbleColors.LIGHT_MAIN_TEXT,
animationSpec = textTween, label = "mainText"
)
val titleColor by animateColorAsState(
if (isDarkTheme) BubbleColors.DARK_TITLE else BubbleColors.LIGHT_TITLE,
animationSpec = textTween, label = "title"
)
val subtitleColor by animateColorAsState(
if (isDarkTheme) BubbleColors.DARK_SUBTITLE else BubbleColors.LIGHT_SUBTITLE,
animationSpec = textTween, label = "subtitle"
)
DeformationFrameLoop(state)
val shader = remember {
if (isAvailable()) {
createAppRuntimeShader(KINEMATIC_LENS_SHADER)
} else null
}
val revealClipPath = remember { Path() }
Box(
modifier = Modifier
.fillMaxSize()
.bubbleDragInput(state, scope)
.bubbleTapInput(state, scope)
.bubbleShaderLayer(state, shader)
.drawBehind {
drawThemeBackground(
isDarkTheme = isDarkTheme,
previousIsDark = previousIsDark,
revealProgress = state.themeRevealProgress.value,
lightBrush = lightBrush,
darkBrush = darkBrush,
reusablePath = revealClipPath,
)
}
) {
ThemeToggleButton(
isDarkTheme = isDarkTheme,
onToggle = {
if (state.themeRevealProgress.isRunning) return@ThemeToggleButton
previousIsDark = isDarkTheme
isDarkTheme = !isDarkTheme
scope.launch {
state.themeRevealProgress.snapTo(0f)
state.themeRevealProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = BubbleConfig.THEME_REVEAL_DURATION,
easing = CubicBezierEasing(0.1f, 0.8f, 0.2f, 1.0f)
)
)
}
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 48.dp, end = 24.dp)
)
Text(
text = "Pixels are now\nphysical.",
fontSize = 32.sp,
fontWeight = FontWeight.Medium,
lineHeight = 40.sp,
color = mainTextColor,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.offset(y = (-80).dp)
.graphicsLayer { alpha = 1f - (state.progress * 4).coerceIn(0f, 1f) }
)
Box(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(x = 0, y = state.textYOffsetPx.roundToInt()) },
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer { alpha = (state.progress * 3).coerceIn(0f, 1f) }
) {
Text(
text = "AGSL Pipelines",
fontSize = 44.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-1).sp,
color = titleColor
)
Text(
text = "Real-time thin-film interference\ndriven by kinematic springs.",
fontSize = 24.sp,
lineHeight = 26.sp,
textAlign = TextAlign.Center,
color = subtitleColor,
modifier = Modifier.padding(top = 16.dp)
)
}
}
if (!isAvailable()) {
BubbleFallback(state)
}
}
}
/**
* Sun/moon toggle for switching between light and dark themes.
*
* Animates between a sun (with rays) and a crescent moon using path operations.
* The crescent is created by subtracting a second circle from the main one
* via [PathOperation.Difference]. Paths are allocated once and reused to
* avoid GC pressure during drawing.
*
* @param isDarkTheme Current theme state.
* @param onToggle Called when the button is tapped.
* @param modifier Modifier for positioning.
*/
@Composable
private fun ThemeToggleButton(
isDarkTheme: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val progress by animateFloatAsState(
targetValue = if (isDarkTheme) 1f else 0f,
animationSpec = spring(dampingRatio = 0.75f, stiffness = 300f),
label = "theme_morph"
)
val scaleAnim = remember { Animatable(1f) }
LaunchedEffect(isDarkTheme) {
scaleAnim.snapTo(0.85f)
scaleAnim.animateTo(
1f,
spring(dampingRatio = 0.6f, stiffness = 400f)
)
}
val mainPath = remember { Path() }
val cutoutPath = remember { Path() }
val finalPath = remember { Path() }
Canvas(
modifier = modifier
.size(48.dp)
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
}
.clip(CircleShape)
.clickable(onClick = onToggle)
.padding(6.dp)
) {
val center = Offset(size.width / 2f, size.height / 2f)
val maxRadius = size.width / 2f
val sunColor = Color(0xFFFDB813)
val moonColor = Color(0xFFE5E5EA)
val currentColor = androidx.compose.ui.graphics.lerp(sunColor, moonColor, progress)
rotate(
degrees = progress * -90f,
pivot = center
) {
val rayAlpha = (1f - progress * 2.5f).coerceIn(0f, 1f)
if (rayAlpha > 0f) {
val rayLength = maxRadius * 0.25f
val rayOffset = maxRadius * 0.6f
for (i in 0 until 8) {
rotate(
degrees = i * 45f,
pivot = center
) {
drawLine(
color = currentColor.copy(alpha = rayAlpha),
start = center.copy(y = center.y - rayOffset),
end = center.copy(y = center.y - rayOffset - rayLength),
strokeWidth = maxRadius * 0.15f,
cap = StrokeCap.Round
)
}
}
}
val sunRadius = maxRadius * 0.45f
val moonRadius = maxRadius * 0.85f
val currentRadius = sunRadius + (moonRadius - sunRadius) * progress
mainPath.reset()
mainPath.addOval(
Rect(
left = center.x - currentRadius,
top = center.y - currentRadius,
right = center.x + currentRadius,
bottom = center.y + currentRadius
)
)
val cutoutStartOffset = Offset(center.x + maxRadius * 2f, center.y - maxRadius * 2f)
val cutoutEndOffset =
Offset(center.x + currentRadius * 0.3f, center.y - currentRadius * 0.3f)
val cutoutX = cutoutStartOffset.x + (cutoutEndOffset.x - cutoutStartOffset.x) * progress
val cutoutY = cutoutStartOffset.y + (cutoutEndOffset.y - cutoutStartOffset.y) * progress
val cutoutRadius = currentRadius * 0.95f
cutoutPath.reset()
cutoutPath.addOval(
Rect(
left = cutoutX - cutoutRadius,
top = cutoutY - cutoutRadius,
right = cutoutX + cutoutRadius,
bottom = cutoutY + cutoutRadius
)
)
finalPath.reset()
finalPath.op(mainPath, cutoutPath, PathOperation.Difference)
drawPath(path = finalPath, color = currentColor)
}
}
}
/**
* Simple gradient circle fallback for devices below API 33 where AGSL is unavailable.
*/
@Composable
private fun BubbleFallback(state: PhysicsBubbleState) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.White.copy(alpha = 0.6f), Color.Transparent),
center = state.bubblePos.value,
radius = state.currentOrbRadius
)
)
}
}
/**
* Per-frame loop that drives the bubble's squash/stretch deformation.
*
* ## How it works
*
* Each frame, we compute the bubble's velocity from its position delta.
* That velocity is smoothed with a low-pass filter and fed as the target
* into a damped spring. The spring is stepped manually using semi-implicit
* Euler integration (not a Compose `animateTo` call) to avoid allocating
* a new coroutine every frame.
*
* The spring parameters (stiffness=1500, damping=34.8) are derived from
* the equivalent Compose spring with `dampingRatio=0.45` and `StiffnessMedium`.
* The relationship is: `damping = 2 * dampingRatio * sqrt(stiffness)`.
*
* Delta time is capped at 32ms to prevent the spring from exploding
* after a lag spike (e.g. GC pause or app backgrounding).
*
* This loop also advances [PhysicsBubbleState.shaderTime] so the AGSL shader
* can animate its noise-based film thickness variation over time.
*/
@Composable
private fun DeformationFrameLoop(state: PhysicsBubbleState) {
LaunchedEffect(state) {
var previousActualPos = state.bubblePos.value
val startTime = withFrameNanos { it }
var lastFrameTime = startTime
var smoothedVelocity = Offset.Zero
var defVelocity = Offset.Zero
val stiffness = 1500f
val damping = 34.8f
while (true) {
val frameTime = withFrameNanos { it }
val dt = ((frameTime - lastFrameTime) / 1_000_000_000f).coerceAtMost(0.032f)
lastFrameTime = frameTime
state.shaderTime[0] = (frameTime - startTime) / 1_000_000_000f
val currentActualPos = state.bubblePos.value
val rawVelocity = currentActualPos - previousActualPos
smoothedVelocity = Offset(
x = smoothedVelocity.x + (rawVelocity.x - smoothedVelocity.x) * BubbleConfig.VELOCITY_SMOOTHING,
y = smoothedVelocity.y + (rawVelocity.y - smoothedVelocity.y) * BubbleConfig.VELOCITY_SMOOTHING
)
if (state.popAnim.value == 0f) {
val targetDeformation = Offset(
x = (smoothedVelocity.x * BubbleConfig.DEFORMATION_FACTOR).coerceIn(
-BubbleConfig.DEFORMATION_CLAMP,
BubbleConfig.DEFORMATION_CLAMP
),
y = (smoothedVelocity.y * BubbleConfig.DEFORMATION_FACTOR).coerceIn(
-BubbleConfig.DEFORMATION_CLAMP,
BubbleConfig.DEFORMATION_CLAMP
)
)
val currentDef = state.deformationAnim.value
val forceX =
(targetDeformation.x - currentDef.x) * stiffness - defVelocity.x * damping
val forceY =
(targetDeformation.y - currentDef.y) * stiffness - defVelocity.y * damping
defVelocity = Offset(defVelocity.x + forceX * dt, defVelocity.y + forceY * dt)
val nextDef =
Offset(currentDef.x + defVelocity.x * dt, currentDef.y + defVelocity.y * dt)
state.deformationAnim.snapTo(nextDef)
} else {
state.deformationAnim.snapTo(Offset.Zero)
defVelocity = Offset.Zero
}
previousActualPos = currentActualPos
}
}
}
/**
* Handles vertical drag gestures to move the bubble.
*
* The bubble starts in a "locked" mode where it can only move vertically
* along the center axis. Once dragged to the top snap position, it "unlocks"
* and allows free XY movement. On release, it snaps to whichever endpoint
* (top or bottom) is closer, using spring animations.
*/
private fun Modifier.bubbleDragInput(
state: PhysicsBubbleState,
scope: CoroutineScope,
): Modifier = pointerInput(Unit) {
var isUnlocked = false
detectDragGestures(
onDragStart = { isUnlocked = state.isAtTop() },
onDragEnd = {
scope.launch {
if (isUnlocked) {
if (state.bubblePos.value.y < state.midPoint) {
state.bubblePos.animateTo(
Offset(state.centerX, state.topOrbCenterY),
UnlockedSnapSpring
)
} else {
state.bubblePos.animateTo(
Offset(state.centerX, state.bottomOrbCenterY),
SnapBackSpring
)
}
} else {
val targetY =
if (state.bubblePos.value.y < state.midPoint) state.topOrbCenterY else state.bottomOrbCenterY
state.bubblePos.animateTo(Offset(state.centerX, targetY), SnapBackSpring)
}
}
}
) { change, dragAmount ->
if (state.popAnim.value > 0f) return@detectDragGestures
change.consume()
val proposedY = state.bubblePos.value.y + dragAmount.y
if (!isUnlocked && proposedY <= state.topOrbCenterY) isUnlocked = true
if (isUnlocked) {
scope.launch {
state.bubblePos.snapTo(Offset(state.bubblePos.value.x + dragAmount.x, proposedY))
}
} else {
val clampedY = proposedY.coerceAtMost(state.maxDragY)
scope.launch { state.bubblePos.snapTo(Offset(state.centerX, clampedY)) }
}
}
}
/**
* Handles tap gestures to pop the bubble.
*
* On tap, the bubble fades out over [BubbleConfig.POP_DURATION] ms,
* waits [BubbleConfig.POP_DELAY] ms, then respawns at the rest position.
*/
private fun Modifier.bubbleTapInput(
state: PhysicsBubbleState,
scope: CoroutineScope,
): Modifier = pointerInput(Unit) {
detectTapGestures(
onTap = {
if (state.popAnim.value == 0f) {
scope.launch {
state.popAnim.animateTo(
1f,
tween(BubbleConfig.POP_DURATION, easing = FastOutLinearInEasing)
)
delay(BubbleConfig.POP_DELAY)
state.popAnim.snapTo(0f)
state.bubblePos.snapTo(Offset(state.centerX, state.bottomOrbCenterY))
}
}
}
)
}
/**
* Applies the AGSL [AppRuntimeShader] as a [RenderEffect] on the graphics layer.
*
* Passes all bubble state (position, radius, deformation, pop progress, time)
* as shader uniforms each frame. The shader receives the composable's rendered
* content as `uniform shader composable` and applies the bubble optics on top.
*
* No-op on API < 33 where [AppRuntimeShader] is unavailable.
*/
private fun Modifier.bubbleShaderLayer(
state: PhysicsBubbleState,
shader: AppRuntimeShader?,
): Modifier = graphicsLayer {
if (isAvailable() && shader != null) {
shader.setFloatUniformValue("touchCenter",state.bubblePos.value.x, state.bubblePos.value.y)
shader.setFloatUniformValue("radius", state.currentOrbRadius)
shader.setFloatUniformValue("progress", state.progress)
shader.setFloatUniformValue("deformation", state.deformationAnim.value.x, state.deformationAnim.value.y)
shader.setFloatUniformValue("popProgress", state.popAnim.value)
shader.setFloatUniformValue("sysTime", state.shaderTime[0])
renderEffect = createAppRuntimeShaderRenderEffect(shader, "composable")
}
}
/**
* Creates a radial gradient [Brush] centered at 40% screen height.
* Used for both light and dark theme backgrounds.
*/
private fun createRadialBrush(
screenWidthPx: Float,
screenHeightPx: Float,
center: Color,
mid1: Color,
mid2: Color,
edge: Color,
): Brush = Brush.radialGradient(
0.0f to center,
0.3f to mid1,
0.7f to mid2,
1.0f to edge,
center = Offset(screenWidthPx / 2f, screenHeightPx * 0.4f)
)
/**
* Draws the theme background with a circular-reveal transition.
*
* During a theme switch, the previous theme's background is drawn first as a full rect.
* The new theme is then drawn inside a growing circular clip path that expands from
* the top-right corner (near the toggle button). Once the reveal completes, only
* the current theme's background is drawn.
*
* Uses [reusablePath] to avoid allocating a new [Path] every frame during the animation.
*/
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawThemeBackground(
isDarkTheme: Boolean,
previousIsDark: Boolean,
revealProgress: Float,
lightBrush: Brush,
darkBrush: Brush,
reusablePath: Path,
) {
val currentBrush = if (isDarkTheme) darkBrush else lightBrush
val prevBrush = if (previousIsDark) darkBrush else lightBrush
drawRect(brush = prevBrush)
if (revealProgress < 1f) {
val maxRadius = hypot(size.width, size.height)
val currentRevealRadius = revealProgress * maxRadius
val epicenter = Offset(size.width - 100f, 150f)
reusablePath.reset()
reusablePath.addOval(
Rect(
left = epicenter.x - currentRevealRadius,
top = epicenter.y - currentRevealRadius,
right = epicenter.x + currentRevealRadius,
bottom = epicenter.y + currentRevealRadius
)
)
clipPath(reusablePath) {
drawRect(brush = currentBrush)
}
} else {
drawRect(brush = currentBrush)
}
}
/**
* AGSL thin-film interference shader.
*
* This shader runs per-pixel on the GPU and produces the soap bubble effect.
* It receives the composable's rendered content as `uniform shader composable`
* and applies the following optical effects:
*
* ## Thin-film interference
*
* Models a thin film of soapy water (n=1.33) bounded by air (n=1.0).
* For each pixel, we compute:
* 1. The surface normal of the sphere from the UV coordinates.
* 2. The refracted angle inside the film using Snell's law.
* 3. The film thickness at that point (varies with gravity, noise, and time).
* 4. The optical path difference: `OPD = 2 * n * d * cos(θt)`.
* 5. The interference intensity for R (650nm), G (532nm), and B (450nm)
* using `0.5 + 0.5 * cos(2π * OPD / λ)`.
*
* ## Fresnel reflection
*
* Uses the Schlick approximation to compute angle-dependent reflectance.
* At the rim (grazing angles), the bubble becomes more reflective and
* the interference colors fade to white.
*
* ## Chromatic aberration
*
* Samples the background at three different offsets for R, G, B channels,
* simulating wavelength-dependent refraction through a curved lens.
*
* ## Environment reflection
*
* Samples the background at normal-offset positions with a 5-tap blur
* to simulate the distorted reflection visible on real bubbles.
*
* ## Volume-preserving deformation
*
* Stretches the bubble along the movement direction and squashes it
* perpendicular to that direction. The transform preserves area:
* `stretch = 1 + speed`, `squash = 1 / sqrt(stretch)`.
*
* ## Tuning knobs (defined at the top of the shader body)
*
* - `THICKNESS_BASE`: Center film thickness in nm. Controls dominant color.
* - `THICKNESS_GRAVITY`: How much gravity thins the top vs thickens the bottom.
* - `THICKNESS_SWIRL` / `THICKNESS_DETAIL`: Noise amplitudes for organic turbulence.
* - `COLOR_INTENSITY`: Vividity multiplier for interference colors.
* - `EDGE_FADE_END`: Where interference fades to white Fresnel at the rim.
* - `ENV_REFLECTION_STRENGTH`: How much of the environment is reflected.
* - `ENV_BLUR_RADIUS`: Blur radius for reflected environment samples.
*/
const val KINEMATIC_LENS_SHADER = """
uniform shader composable;
uniform float2 touchCenter;
uniform float radius;
uniform float progress;
uniform float2 deformation;
uniform float popProgress;
uniform float sysTime;
float hash(float2 p) {
return fract(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
float smoothNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x), u.y);
}
half4 main(float2 fragCoord) {
// ================================================================
// TUNING KNOBS — adjust these to taste
// ================================================================
// Film thickness center (nm). Controls the dominant color.
// 200–300 = blues/violets, 300–400 = greens/yellows,
// 400–550 = oranges/pinks, 550+ = higher-order pastels.
float THICKNESS_BASE = 300.0;
// How much gravity thins the top vs thickens the bottom (nm).
// 0 = uniform thickness. Higher = more color variation top-to-bottom.
float THICKNESS_GRAVITY = 120.0;
// Noise amplitude — organic swirly turbulence (nm).
// 0 = clean bands. Higher = more chaotic, soap-like flow.
float THICKNESS_SWIRL = 100.0;
float THICKNESS_DETAIL = 40.0;
// Color intensity multiplier. Controls how vivid the interference is.
// 1.0 = physically dim (barely visible). 2.0 = natural soap bubble.
// 4.0+ = exaggerated/artistic.
float COLOR_INTENSITY = 2.0;
// Edge white fade — where interference fades to white Fresnel.
// Lower = fade starts earlier (more white rim). Higher = colors
// extend further to the edge. Range: 0.05 – 0.4
float EDGE_FADE_END = 0.20;
// Ambient environment reflection strength.
// 0.0 = no environment reflection. 1.0 = full mirror.
// 0.3–0.5 = subtle, realistic bubble environment pickup.
float ENV_REFLECTION_STRENGTH = 0.4;
// Environment blur radius (pixels). How blurry the reflected
// environment appears. Real bubbles show very distorted reflections.
// 30–80 = natural. Higher = more diffuse.
float ENV_BLUR_RADIUS = 50.0;
// ================================================================
half4 rawBackground = composable.eval(fragCoord);
if (popProgress >= 1.0) return rawBackground;
float2 rawUv = fragCoord - touchCenter;
float speed = length(deformation);
float2 moveDir = speed > 0.001 ? deformation / speed : float2(0.0, 1.0);
float parallelDist = dot(rawUv, moveDir);
float2 perpVector = rawUv - moveDir * parallelDist;
float stretch = 1.0 + speed;
float squash = 1.0 / sqrt(stretch);
float2 uv = (moveDir * (parallelDist / stretch)) + (perpVector / squash);
float dist = length(uv);
float activeRadius = radius * (1.0 + popProgress * 1.5);
if (dist >= activeRadius) {
return rawBackground;
}
// Surface geometry
float2 nUv = uv / activeRadius;
float distSq = dot(nUv, nUv);
float z = sqrt(max(0.0, 1.0 - distSq));
float3 normal = normalize(float3(nUv, z));
float3 viewDir = float3(0.0, 0.0, 1.0);
float NdotV = max(0.0, dot(normal, viewDir));
// Refraction with chromatic aberration
float magnification = 0.45;
float lensDeform = (1.0 - z) * magnification * (1.0 - popProgress);
float2 refUvR = fragCoord - (nUv * activeRadius * (lensDeform * 0.88));
float2 refUvG = fragCoord - (nUv * activeRadius * (lensDeform * 1.00));
float2 refUvB = fragCoord - (nUv * activeRadius * (lensDeform * 1.12));
half3 bgColor = half3(
composable.eval(refUvR).r,
composable.eval(refUvG).g,
composable.eval(refUvB).b
);
// Lighting vectors
float3 reflectionDir = reflect(-viewDir, normal);
float3 lightDir1 = normalize(float3(0.6, 0.7, 0.8));
float3 lightDir2 = normalize(float3(-0.5, -0.4, 0.6));
float lightAlign1 = max(0.0, dot(reflectionDir, lightDir1));
float lightAlign2 = max(0.0, dot(reflectionDir, lightDir2));
// ================================================================
// PHYSICALLY-BASED THIN-FILM INTERFERENCE
//
// Models a soap bubble: thin film of soapy water (n=1.33)
// bounded by air (n=1.0) on both sides.
//
// Physics:
// 1. Snell's law: sin(θ_i) = n_film * sin(θ_t)
// 2. Optical path difference: Δ = 2 * n_film * d * cos(θ_t)
// 3. Phase shift of π at first surface (air→film, low-to-high n)
// 4. Reflectance per λ: R(λ) = 2*R0*(1 - cos(2π·Δ/λ + π))
// which simplifies to: R(λ) = 2*R0*(1 + cos(2π·Δ/λ))
// 5. Fresnel R0 at normal incidence = ((n-1)/(n+1))^2
// 6. Full Fresnel via Schlick approximation for angle dependence.
//
// We sample the visible spectrum at three representative wavelengths:
// R ≈ 650nm, G ≈ 532nm, B ≈ 450nm
// ================================================================
// Film properties
float n_film = 1.33; // Refractive index of soapy water
float n_air = 1.0;
// Fresnel reflectance at normal incidence: ((n1-n2)/(n1+n2))^2
float R0 = pow((n_film - n_air) / (n_film + n_air), 2.0); // ≈ 0.02
// Schlick Fresnel — angle-dependent reflectance
float fresnel = R0 + (1.0 - R0) * pow(1.0 - NdotV, 5.0);
// Snell's law: cos(θ_t) inside the film
// sin(θ_i) = sqrt(1 - cos²(θ_i)) = sqrt(1 - NdotV²)
float sinThetaI = sqrt(max(0.0, 1.0 - NdotV * NdotV));
float sinThetaT = sinThetaI / n_film;
float cosThetaT = sqrt(max(0.0, 1.0 - sinThetaT * sinThetaT));
// Film thickness varies across the surface — thinner at top (gravity),
// thicker at bottom, with fluid noise for organic turbulence.
// Range ~100nm to ~800nm covers the full visible interference spectrum.
float swirl = smoothNoise(nUv * 3.0 + sysTime * 0.12);
float thicknessNoise = smoothNoise(nUv * 5.0 - sysTime * 0.08);
// Base thickness: gravity thins the top, thickens the bottom.
// nUv.y goes from -1 (top of sphere) to +1 (bottom).
float baseThickness = THICKNESS_BASE + nUv.y * THICKNESS_GRAVITY;
float thickness = baseThickness
+ swirl * THICKNESS_SWIRL
+ thicknessNoise * THICKNESS_DETAIL;
// Clamp to physically meaningful range
thickness = clamp(thickness, 80.0, 900.0);
// Optical path difference (nm)
float opd = 2.0 * n_film * thickness * cosThetaT;
// Wavelengths in nm for RGB channels
float lambda_R = 650.0;
float lambda_G = 532.0;
float lambda_B = 450.0;
// Thin-film reflectance per channel.
//
// The interference oscillation gives vivid color across most of the
// surface. Only at the very edge (grazing angles) do we fade toward
// uniform white Fresnel — on a real bubble, the extreme rim goes
// silvery because all wavelengths reflect almost equally there.
//
// The key balance: interference stays vivid across ~85% of the
// surface. The white fade only kicks in at the outermost rim.
float TWO_PI = 6.2831853;
// Interference oscillation per channel: ranges [0, 1]
float oscR = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_R);
float oscG = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_G);
float oscB = 0.5 + 0.5 * cos(TWO_PI * opd / lambda_B);
half3 interferenceColor = half3(oscR, oscG, oscB);
// Only fade interference at the very edge — NdotV < 0.15 is the
// outermost ~15% of the sphere where grazing-angle washout happens.
float interferenceStrength = smoothstep(0.0, EDGE_FADE_END, NdotV);
// Amplify the interference color — pure Fresnel * [0,1] is too dim
// because R0 ≈ 0.02 at normal incidence. Real bubbles appear more
// vivid than bare Fresnel predicts because:
// - Both surfaces of the film reflect (constructive doubling)
// - The eye adapts to the transparency and perceives the color
// - Environment light contributes from all angles
//
// We scale by 2.5 to approximate the double-surface amplification
// and perceptual brightness, while keeping it energy-plausible.
half3 filmReflection = interferenceColor * fresnel * COLOR_INTENSITY;
// At the rim, blend toward clean white Fresnel (no color)
half3 whiteReflection = half3(fresnel);
half3 thinFilmColor = mix(whiteReflection, filmReflection, interferenceStrength);
// ================================================================
// SPECULAR HIGHLIGHTS — physically motivated
//
// Blinn-Phong model with two light sources.
// The high exponent on light1 gives a tight sun-like glint.
// Light2 is broader and dimmer — an environment fill.
// ================================================================
float spec1 = pow(lightAlign1, 250.0) * 2.5;
float spec2 = pow(lightAlign2, 60.0) * 0.5;
half3 highlights = half3(spec1 + spec2);
// ================================================================
// AMBIENT ENVIRONMENT REFLECTION
//
// Real bubbles reflect their surroundings — you can see distorted
// trees, sky, objects in a soap bubble. We approximate this by
// sampling the composable (screen content) at offset positions
// based on the surface normal, creating a blurred pseudo-reflection.
//
// We take 5 samples in a cross pattern around the reflection point
// to simulate the blurry, distorted reflection a curved film produces.
// The reflection is modulated by Fresnel — stronger at edges
// (where real reflections are most visible on bubbles).
// ================================================================
float2 reflectOffset = normal.xy * ENV_BLUR_RADIUS;
float2 envCenter = fragCoord + reflectOffset;
// 5-tap blur: center + 4 cardinal offsets for a soft box
float blurStep = ENV_BLUR_RADIUS * 0.4;
half3 envSample = composable.eval(envCenter).rgb * 0.4
+ composable.eval(envCenter + float2(blurStep, 0.0)).rgb * 0.15
+ composable.eval(envCenter - float2(blurStep, 0.0)).rgb * 0.15
+ composable.eval(envCenter + float2(0.0, blurStep)).rgb * 0.15
+ composable.eval(envCenter - float2(0.0, blurStep)).rgb * 0.15;
// Modulate by Fresnel and user-controlled strength
half3 envReflection = envSample * fresnel * ENV_REFLECTION_STRENGTH;
// ================================================================
// COMPOSITE
// ================================================================
// Rim darkening — simulates light absorption at grazing angles
// where the optical path through the film is longest.
float rimShadow = smoothstep(0.92, 1.0, sqrt(distSq));
bgColor *= (1.0 - rimShadow * 0.25);
// The refracted background shows through (1 - reflectance),
// thin-film color and environment reflection are additive on top,
// specular highlights go on last — energy-conserving blend.
half3 finalColor = bgColor * (1.0 - half3(fresnel))
+ thinFilmColor
+ envReflection
+ highlights;
float fadeOut = 1.0 - pow(popProgress, 0.5);
return half4(mix(rawBackground.rgb, finalColor, fadeOut), rawBackground.a);
}
"""
import android.app.Activity
import android.graphics.RuntimeShader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
actual typealias AppRuntimeShader = RuntimeShader
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
actual fun createAppRuntimeShader(shaderCode: String): AppRuntimeShader = RuntimeShader(shaderCode)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
actual fun createAppRuntimeShaderRenderEffect(
shader: AppRuntimeShader,
name: String
): RenderEffect {
return android.graphics.RenderEffect.createRuntimeShaderEffect(shader, name).asComposeRenderEffect()
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
actual fun AppRuntimeShader.setFloatUniformValue(
name: String,
value: Float
) = setFloatUniform(name, value)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
actual fun AppRuntimeShader.setFloatUniformValue(
name: String,
value1: Float,
value2: Float
) = setFloatUniform(name, value1, value2)
actual fun isAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
}
@Composable
actual fun StatusBarChangeIfNeeded(isDarkTheme: Boolean) {
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars =
!isDarkTheme
}
}
}
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.RenderEffect
import androidx.compose.ui.graphics.asComposeRenderEffect
import org.jetbrains.skia.ImageFilter
import org.jetbrains.skia.RuntimeEffect
import org.jetbrains.skia.RuntimeShaderBuilder
actual typealias AppRuntimeShader = RuntimeShaderBuilder
actual fun createAppRuntimeShader(shaderCode: String): AppRuntimeShader {
val effect = RuntimeEffect.makeForShader(shaderCode)
return RuntimeShaderBuilder(effect)
}
actual fun createAppRuntimeShaderRenderEffect(
shader: AppRuntimeShader,
name: String
): RenderEffect {
return ImageFilter.makeRuntimeShader(shader, name, null).asComposeRenderEffect()
}
actual fun AppRuntimeShader.setFloatUniformValue(
name: String,
value: Float
) = uniform(name, value)
actual fun AppRuntimeShader.setFloatUniformValue(
name: String,
value1: Float,
value2: Float
) = uniform(name, value1, value2)
actual fun isAvailable(): Boolean = true
@Composable
actual fun StatusBarChangeIfNeeded(isDarkTheme: Boolean) = Unit
@Mikkareem
Copy link
Copy Markdown
Author

Mikkareem commented Apr 5, 2026

Thanks to Kyriakos-Georgiopoulos

Desktop Version

PhysicsBubble.mp4

IOS Version

PhysicsBubble_ios.mp4

Android Version

PhysicsBubble_android.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment