All presentations

Talk · Interactive edition

ComplexAnimations

From a square that simply slides across the screen, to a GPU-driven disintegration effect. This is the same journey as the talk — the grammar of motion, the dust, the performance wall, and the shaders that break through it — rebuilt so you can touch every idea.

Tehran, Iran 1h 05m Persian Recorded
Watch the talk

The full talk

Watch the original — 1 hour, 5 minutes

Start here if you want the complete journey in Persian — every demo live, with the asides and dead-ends that make the talk worth watching. Then scroll on for the interactive companion below.

Open on YouTube Continue the interactive journey

How everything began

It starts with a question: why does one animation feel cheap, and another feel alive? The difference is rarely a single trick. It is many small, well-understood techniques, layered until motion stops looking computed and starts looking organic.

The destination is a “Thanos effect” — a view that crumbles into thousands of drifting particles. But to build it well, we have to earn it: starting from the four transforms every animation is made of, and ending at the GPU. Every section below is something you can poke at.

01

Fundamentals

Transforms, matrices and interpolators — the alphabet of motion.

02

Designing

Randomness and Perlin noise turn mechanical motion organic.

03

The Effect

A particle system that disintegrates a view, frame by frame.

04

Performance

Where Canvas runs out of road — and the tricks that don't save it.

05

OpenGL ES

Shaders, point sprites, and rendering tens of thousands of points.

01

The fundamentals

Five transforms, a little matrix algebra, and the curves that give motion its feel.

Almost every 2D animation is built from five primitive transforms. Drag the controls below — this single shape is doing everything a UI animation ever does.

Transform playground

Every transform is just a 3×3 matrix multiply applied to each point.

On Android you rarely write the math — you animate a property and the framework interpolates it:

ObjectAnimator.ofFloat(view, "translationX", 100f).apply {
    duration = 1000
    start()
}

But a single property only gets you so far. To compose translate, scale and rotate together, you animate a matrix and let a MatrixEvaluator blend between the start and end:

val target = Matrix().apply {
    postTranslate(100f, 100f)
    postScale(1.2f, 1.2f)
}

ObjectAnimator.ofObject(
    view,
    "animationMatrix",
    MatrixEvaluator(),
    view.animationMatrix ?: Matrix(),
    target,
).apply {
    duration = 1000
    start()
}

So… how does a matrix actually move a point?

Each transform is a 3×3 matrix; applying it is one multiply against a point written as [x, y, 1]. Pick a transform and drag the point — the matrix and the result update live.

Matrix · point

Order matters

Matrix multiplication is not commutative. Translate × Scale is not the same as Scale × Translate — the second scales your offset too. Watch the same point reach two different homes:

Translate × Scale

Scale × Translate

Interpolators give motion its feel

A transform tells you where; an interpolator tells you how it gets there. The same move feels mechanical as a straight line and lively with an overshoot. Watch four dots race the same distance under four different curves:

Interpolators

  • Linearf(t) = t
  • Ease Inf(t) = t²
  • Ease In-Outcosine
  • Overshoottension

Click a curve to isolate it.

02

Designing complex motion

The recipe — and why a dose of randomness is the secret ingredient.

Fundamentals + Interpolators + Randomness

Randomise durations, start delays, amplitudes and directions, and the motion stops marching in lockstep. But pure randomness is jittery and ugly. What we actually want is coherent randomness — values that wander smoothly. That is exactly what noise gives us.

Random vs. noise

On the left, every cell is independent random — pure static. On the right, the same range of values, but smoothly interpolated (gradient / Perlin noise). One is chaos; the other breathes.

White noise (random)

Gradient noise (Perlin)

Sampled over time, Perlin noise becomes an organic signal you can feed into anything — a wobbling radius, a drifting position, a flickering alpha. In the talk it animates a circle's edge until it ripples like something alive:

// animate Perlin noise over time to wobble the edge
vec2 p = fragCoord * noiseScale + u_time * noiseSpeed;
float n = perlinNoise(p);                      // organic variation
float noisyRadius = radius + amplitude * (n - 0.5);

if (distance(fragCoord, center) <= noisyRadius)
    gl_FragColor = circleColor;
03

Building the dust effect

Three steps, one little physics formula, thousands of particles.

  1. 1
    Snapshot the view into a bitmap.
  2. 2
    Split that bitmap into one particle per Nth pixel.
  3. 3
    Animate every particle independently until it dies.

A particle is tiny — just where it started, its color, and a few motion parameters. It exposes one method: advance yourself by deltaTime, and report whether you're still alive.

data class Particle(
    val initialX: Int,
    val initialY: Int,
    val color: Int,
    val lifeTime: Long = 2000,
    val initialRadius: Float = 6f,
    val velocity: Float = 25f,
    val translationX: Float = 250f,
    val translationY: Float = 250f,
    val initialAlpha: Int = Color.alpha(color),
) {
    var x = initialX.toFloat()
    var y = initialY.toFloat()
    var r = initialRadius
    var alpha = initialAlpha
    private var time = 0f

    fun update(deltaTime: Float): Boolean = TODO()
}

Generating them is a double loop over the bitmap; drawing them is a loop over the list. Stepping by perPx pixels keeps the particle count sane.

// 1 — snapshot the view into a bitmap
fun View.takePicture(): Bitmap =
    Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        .also { draw(Canvas(it)) }

// 2 — split that bitmap into one particle per Nth pixel
fun generateParticles(bitmap: Bitmap, perPx: Int = 6) = buildList {
    for (x in 0 until bitmap.width step perPx)
        for (y in 0 until bitmap.height step perPx)
            add(Particle(x, y, color = bitmap.getPixel(x, y)))
}

// 3 — draw every living particle, each frame
fun draw(canvas: Canvas, paint: Paint, deltaTime: Float) {
    for (p in particles) {
        if (p.update(deltaTime)) {
            paint.color = p.color
            paint.alpha = p.alpha
            canvas.drawCircle(p.x, p.y, p.r, paint)
        }
    }
}

The update function — the whole effect, really

Each particle pushes outward from the centre (so the edges fly faster than the middle), gets a parabolic gravity nudge, then shrinks and fades as its life runs out:

fun update(deltaTime: Float): Boolean {
    time += deltaTime
    val fraction = time / lifeTime
    if (fraction >= 1f) return false   // particle has died

    // spread outward, relative to each particle's distance from center
    x = initialX + (initialX - centerX) / centerX * translationX * fraction
    y = initialY + (initialY - centerY) / centerY * translationY * fraction

    // a touch of gravity: rise, then fall
    y -= (fraction * velocity).pow(2f)

    // shrink and fade as it dies
    r = max(0.5f, initialRadius * (1f - fraction))
    alpha = (initialAlpha * min(1.2f - fraction, 1f)).toInt()
    return true
}

Live particle system

Lower step = more particles, finer dust. Higher step = fewer, chunkier pieces.

With uniform values it reads like a grid exploding. Add the randomness from the talk — varied lifetimes, radii, velocities, and a noise-driven sideways drift biased 75% one way — and it finally looks like dust:

data class Particle(
    // …
    val lifeTime: Long = 1200L + (0..800).random(),
    val initialRadius: Float = 6f / (1..4).random(),
    val velocity: Float = 15f + (0..20).random(),
    val translationX: Float = 250f + (0..200).random(),
    val translationY: Float = 150f + (0..200).random(),
    val initialAlpha: Int = min(Color.alpha(color), (76..255).random()),
)

// …and inside update(), let noise wobble the path:
y -= noiseFunction(fraction)
x += noiseFunction(fraction) *
    arrayOf(-1f, 1f, 1f, 1f).random()   // bias 75% one way
04

Then performance happens

It looks great with a few hundred particles. A real view has tens of thousands.

Drawing thousands of circles on the main thread every frame tanks the UI. First move: get off the main thread with a TextureView or SurfaceView, rendering on a dedicated thread. That helps — but the motion still stutters, because frame times aren't uniform. So we tune the delta:

fun timeTuning(
    lastRenderTime: Long,
    now: Long = System.nanoTime(),
    refreshRate: Int = 60,
    minDelta: Double = 1.0 / refreshRate,
    maxDelta: Double = minDelta * 4,
): Double {
    var delta = (now - lastRenderTime) / 1_000_000_000.0
    if (delta < minDelta) {
        Thread.sleep(((minDelta - delta) * 1000).toLong()) // render thread
        delta = minDelta
    } else if (delta > maxDelta) {
        delta = maxDelta   // never let one slow frame teleport everything
    }
    return delta
}

Better — but on a busy device the render thread still can't keep up, and the animation visibly lags behind. So the talk goes hunting for fill-rate, one idea at a time. Each one trades something away:

  • Skip more pixels Fewer particles, but the effect gets coarse and blocky.
  • Draw rectangles, kill anti-aliasing Faster, but “I like circles :(”.
  • Animate two big bitmaps instead Oops — you can't disintegrate halves into dust.

Every Canvas trick costs either quality or the very thing that makes the effect beautiful. The honest conclusion: this is the wrong tool. Per-pixel work at this scale belongs on the GPU.

05

Breaking through with OpenGL ES

Move the particles to the GPU and tens of thousands become cheap.

A GPU runs your code in parallel across every vertex and every pixel. Two small programs — shaders — do the work:

Vertex shader

Moves geometry

Runs once per point. Decides where each particle lands on screen via gl_Position — this is where our update math now lives.

Fragment shader

Colors pixels

Runs once per pixel of each point, deciding its final color — and, crucially, whether to draw it at all.

A circle, with no circle-drawing

We render each particle as a square point sprite, then carve a circle out of it in the fragment shader. gl_PointCoord runs 0→1 across the sprite; remap it to −1→1 and discard any pixel outside the unit circle. Drag to scale the sprite and watch the discarded corners:

Point sprite → circle

// gl_PointCoord runs [0, 1] across the point sprite
vec2 coord = 2.0 * gl_PointCoord - 1.0;   // remap to [-1, 1]

// outside the unit circle? throw the pixel away
if (dot(coord, coord) > 1.0) discard;

gl_FragColor = v_color;

That single discard is the whole trick — no curves, no anti-aliasing cost, just throwing away corner pixels. Multiply by tens of thousands of points running in parallel and the effect that murdered the Canvas now runs effortlessly.

The real shaders carry color, alpha and motion per point. They're trimmed here for clarity — the talk's implementation was simplified for timing too.

And in Jetpack Compose, it's one modifier

All of that — capture, particle generation, GPU rendering — collapses into a single modifier. Tap the image, it turns to dust:

@Composable
fun MainScreen() {
    val scope = rememberCoroutineScope()
    val thanos = rememberThanosEffect()
    val context = LocalContext.current

    Box {
        if (!thanos.hasEffectStarted()) {
            Image(
                painter = painterResource(R.drawable.img),
                contentDescription = null,
                modifier = Modifier
                    .thanosEffect(thanos)
                    .clickable {
                        scope.launch { thanos.start(context) }
                    },
            )
        }
    }
}