Metaball Liquid

Drifting blobs that fuse into a single liquid mass when they overlap. The fusion comes from additive blend on stacked radial gradients.

Compose APIs
withFrameNanosCanvasBrush.radialGradientBlendMode.Plus
Try tweaking
ballCountballRadiusMin/MaxDpglowRadiusMultmaxSpeedDpPerSecdriftNoise

True metaballs are an isosurface technique. You define a scalar field as the sum of falloff functions centered on each ball, then run marching squares (or marching cubes in 3D) to extract the contour where the field equals a threshold. The math is beautiful, and on a desktop GPU it is fine, but on a phone it is wasteful for a decorative effect. There is a much cheaper trick that reads as metaballs to the eye: stack soft radial gradients on top of each other with an additive blend, and let the alpha values pile up where the blobs overlap. That is exactly what AnimationExample18 does.

In this article, you'll explore how the example assembles drifting blobs with withFrameNanos, Canvas, Brush.radialGradient, and BlendMode.Plus to fake a liquid metaball look on commodity mobile hardware.

How the example is structured

The composable holds seven Ball18 instances inside a BoxWithConstraints. Each ball owns a position, a velocity, a radius, and an index into a four color palette. A LaunchedEffect runs an infinite frame loop that integrates positions from velocities, applies a tiny random kick each frame, and bounces balls off the canvas edges. A Canvas reads the same balls list and paints each one as a radial gradient with BlendMode.Plus, optionally adding a small opaque core and a white highlight to sell the wet, glassy look.

The whole canvas runs through graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen). That offscreen layer is what lets the additive blend operate against a stable backdrop instead of compositing through whatever happens to be behind the composable. Without it, BlendMode.Plus would interact with the screen contents and the colors would shift.

A tick long is incremented every frame and read inside the Canvas block. That read is the subscription that forces Compose to redraw. The balls themselves are mutable, but Compose does not observe their fields, so the tick counter is the trigger that flushes the new positions to the screen.

Modeling a ball

The ball type is a tiny mutable record. Position and velocity are var, radius and color index are val because they never change after construction.

private data class Ball18(
  var x: Float,
  var y: Float,
  var vx: Float,
  var vy: Float,
  val r: Float,
  val colorIndex: Int,
)

The reason position and velocity are mutable fields rather than Compose state is performance. With seven balls and a 60 frame per second loop, allocating new immutable instances every frame would burn the garbage collector for no visual benefit. The tick state above does the job of triggering recomposition once per frame, and the canvas pulls the latest field values directly.

Initialization happens inside remember so the list survives recomposition. The seed Random(0xB10B5L) is fixed, which means the layout is deterministic on first frame. Each ball gets a radius between 28 dp and 52 dp, a starting position clamped so the entire disc fits inside the canvas, and an initial velocity scaled to 60 percent of maxSpeedPx so nothing starts at the speed limit.

Driving motion with withFrameNanos

The motion loop sits in a LaunchedEffect keyed on the tweakable constants. Re keying ensures that if you change ballCount or maxSpeedDpPerSec the effect restarts with fresh values rather than holding onto a stale closure.

LaunchedEffect(ballCount, ballRadiusMinDp, ballRadiusMaxDp, maxSpeedPx, driftNoisePx) {
  var lastNanos = 0L
  val rng = Random(0xF1A5C0L)
  while (true) {
    withFrameNanos { now ->
      val dt = if (lastNanos == 0L) 0f
        else ((now - lastNanos) / 1_000_000_000f).coerceAtMost(0.05f)
      lastNanos = now

withFrameNanos suspends until the choreographer is ready to draw the next frame, then hands back the timestamp in nanoseconds. The first frame produces dt = 0f because there is no previous timestamp to subtract from, which prevents an enormous initial jump. The coerceAtMost(0.05f) clamp is a safety net for janky frames or backgrounded apps. If the system stalls for half a second, you do not want every ball to teleport across the canvas.

Inside the same lambda the loop integrates each ball.

for (b in balls) {
  b.vx += (rng.nextFloat() * 2f - 1f) * driftNoisePx * dt
  b.vy += (rng.nextFloat() * 2f - 1f) * driftNoisePx * dt
  val speed = hypot(b.vx, b.vy)
  if (speed > maxSpeedPx) {
    val s = maxSpeedPx / speed
    b.vx *= s
    b.vy *= s
  }
  b.x += b.vx * dt
  b.y += b.vy * dt

The first two lines apply a per axis random impulse scaled by driftNoisePx and dt. Multiplying by dt keeps the noise frame rate independent. The next block clamps the speed using hypot, which gives the Euclidean magnitude of the velocity vector. If you exceed the cap, both components are scaled by the same ratio so direction is preserved. Then position integrates from velocity using a simple explicit Euler step.

Edge handling is a four way clamp and reflect.

if (b.x - b.r < 0f) { b.x = b.r; b.vx = -b.vx }
if (b.x + b.r > widthPx) { b.x = widthPx - b.r; b.vx = -b.vx }
if (b.y - b.r < 0f) { b.y = b.r; b.vy = -b.vy }
if (b.y + b.r > heightPx) { b.y = heightPx - b.r; b.vy = -b.vy }

The position is snapped back inside the canvas before the velocity flips, which prevents a ball from getting stuck oscillating across an edge. After all balls update, tick++ triggers the canvas to redraw.

The radial gradient as a soft particle

Each ball is rendered as a single radial gradient that fades from the core color to fully transparent. The brush is built per frame because the center moves.

val glowRadius = b.r * glowRadiusMult
val brush = Brush.radialGradient(
  colorStops = arrayOf(
    0f to core,
    gradientMidStop to core.copy(alpha = gradientMidAlpha),
    1f to Color.Transparent,
  ),
  center = center,
  radius = glowRadius,
)
drawCircle(brush = brush, radius = glowRadius, center = center, blendMode = BlendMode.Plus)

The three stops are the shape of the falloff. At the center, alpha is full. At gradientMidStop = 0.45f, alpha drops to gradientMidAlpha = 0.70f. At the outer edge of glowRadius, alpha is zero. The middle stop is what tunes the visible "edge" of a ball. Without it, a two stop gradient from solid to transparent looks like a fuzzy fog with no apparent boundary. The middle stop creates a soft shoulder that the eye reads as the ball's surface.

The drawn radius matches the gradient radius, so the disc covers exactly the area where the gradient has any contribution. Drawing a larger circle would waste fill rate on fully transparent pixels, and a smaller one would clip the soft halo and produce a hard edge.

The visible "ring" of the metaball is the locus of pixels where the summed alpha across all overlapping gradients crosses the eye's perceptual threshold. That is the cheap analog of an isosurface. You are not solving for a contour, you are letting the screen integrate alpha for you.

Additive blend for the fusion effect

The single line that turns this from "circles that overlap" into "blobs that fuse" is blendMode = BlendMode.Plus. Plus is an additive blend: the destination channel becomes the sum of source and destination, clamped to one. When two soft circles overlap, the alpha values add in the overlap region. The shoulder that looked like the edge of one ball now sits on top of the shoulder of another, and the combined alpha pushes deeper into the perceptual "solid" zone. The result reads as a single bulging mass.

This is also why the offscreen layer matters. CompositingStrategy.Offscreen allocates a separate buffer that the canvas draws into, then composites onto the screen. Without it, BlendMode.Plus would add to whatever pixels are already on screen behind the composable, and the colors would drift toward white as you scroll over different backgrounds. With the offscreen buffer, every frame starts from the dark bgColor = Color(0xFF080812) and the additive math is predictable.

The optional inner core uses the same blend mode for consistency.

drawCircle(
  color = ballColor.copy(alpha = 0.95f),
  radius = max(1f, coreRadius),
  center = Offset(b.x, b.y),
  blendMode = BlendMode.Plus,
)
drawCircle(
  color = Color.White.copy(alpha = 0.55f),
  radius = max(1f, coreRadius * 0.45f),
  center = Offset(b.x - coreRadius * 0.25f, b.y - coreRadius * 0.25f),
  blendMode = BlendMode.Plus,
)

The first call paints a small opaque disc at the ball's center, sized at 35 percent of the ball radius via innerCoreFraction. The second call paints a smaller white spot offset up and to the left, which reads as a specular highlight. Both use BlendMode.Plus so they brighten the underlying gradient instead of replacing it.

Tweaking ball count, radius, glow, speed, drift

The constants at the top of the function are the tuning surface.

ballCount = 7 controls how many blobs participate. Three feels sparse and you rarely see fusion. Fourteen is chaos with constant overlap. Seven is the goldilocks zone where fusion happens often enough to be the visual story, but each ball is still legible as a separate body part of the time.

ballRadiusMinDp = 28f and ballRadiusMaxDp = 52f set the size range. The variation matters more than the absolute values. With identical radii, the fused mass looks regular and machine made. With a wide range, the asymmetric fusion produces irregular shapes that read as organic.

glowRadiusMult = 2.2f is the fusion intensity dial. The drawn circle radius is r * glowRadiusMult, so at 2.2 each ball's gradient extends well past its nominal radius. Lower values like 1.2 produce crisp, barely fusing blobs. Higher values like 4.0 produce a heavy, soupy fusion where individual balls disappear into the mass.

maxSpeedDpPerSec = 90f caps velocity. At low values the scene looks like a lava lamp. At high values the balls feel frantic and the fusion barely lasts long enough to register.

driftNoise = 50f is the per frame velocity perturbation. Zero produces straight line ballistic trajectories that bounce predictably off walls. Higher values like 200 produce a jittery, agitated motion that never settles into a rhythm. Fifty gives the balls a wandering, slightly bored quality that suits the lava lamp metaphor.

Conclusion

In this article, you've explored how AnimationExample18 builds a metaball liquid effect by combining withFrameNanos driven motion, soft radial gradients, and additive blending inside an offscreen layer. The motion is plain Euler integration with random impulses and reflective walls. The visual fusion is the byproduct of stacked alpha values exceeding a perceptual threshold. The offscreen graphicsLayer is what makes the blend predictable.

Understanding this trick helps you decide when to reach for the real algorithm and when the cheap version is enough. True isosurface extraction matters when you need a clean polygonal contour, controllable surface tension, or interaction with light and shadow. For a decorative background, a loading state, or a brand moment, the gradient stack runs at full frame rate on any phone made in the last five years and the eye cannot tell the difference.

Whether you are building an ambient background animation, a playful loading indicator, or an attention drawing accent, this pattern gives you a reliable way to produce organic, liquid motion without a math heavy implementation. The same blend mode plus offscreen layer combination scales to glow effects, additive particles, and any other place where you want light to accumulate rather than replace.

As always, happy coding!

Jaewoong (skydoves)