Sept 29 2020, Saturday

Path Tracing with Jetpack Compose

Ever since Jetpack Compose went alpha I have been excited to start experimenting with the new APIs.

One of the first demo's that blew me away and forced me to learn and explore the old graphics APIs is a series of articles written by Romain Guy. One article in particular which I enjoyed reading was about Path Tracing (A novel use of PathEffect).

I have also been watching a lot of Frasier and the show has a really elegant path tracing animation. Here is what it looks like.


I thought it might be fun to build this animation as a way to learn how to do custom drawing and animations using Compose.

Building the demo

SVG

I searched for some vector artwork that represented the path coordinates in the animation. I found an SVG that had what I needed.

I did not want to parse SVG to get to the actual coordinates. I discovered a wonderful tool called Coordinator which converts SVG to XY coordinates. It was perfect for my usecase. Here are what the output coordinates looked like:

[[40.75,851.7940063476562],[45.798627853393555,851.3941040039062],[50.20281219482422,848.9244995117188],[52.9980583190918,844.7092895507812], ...]

Custom Drawing in Compose

Now that we have the coordinates, we just need to draw Paths on the Canvas. To do custom drawing, we need to use the Canvas @Composable. Here is a stub for the composable.

// `points` are the set of points being drawn.
// `endOffset` represents the point until which the part has been animated. Starts off from the second point.
@Composable
fun PathTracer(points: List<Offset>, endOffset: Int = 2) {
    var canvasSize by remember { mutableStateOf(Size.Zero) }
    Canvas(modifier = Modifier
        .padding(2.dp)
        .background(Color.LightGray)
        .fillMaxSize()
    ) {
        if (canvasSize == Size.Zero) {
            canvasSize = size
        } else {
            // Now do the drawing.
        }
    }
}

The first thing we need to do is to keep track of the size of the Canvas. We need the size of the Canvas because we need to scale the path coordinates based on the canvas size.

The Canvas size can only be determined when Canvas is being drawn. Therefore we need to defined a mutableStateOf(Size) to keep track of the size. Next, we scale points based on the size of the Canvas and create a Path that needs to be drawn. Here is the full source code for the actual composable.

@Composable
fun PathTracer(points: List<Offset>, endIndex: Int = 2) {
    if (points.isNotEmpty()) {
        var canvasSize by remember { mutableStateOf(Size.Zero) }
        // minX, max X coordinate
        val (minX, maxX) = remember(points) {
            val coordinates = points.asSequence().map { it.x }
            // use 0f for start because it's scales coordinates interestingly otherwise
            0f to coordinates.maxOrNull()!!
        }
        val path = remember(canvasSize, points) {
            // recreate path only if the size of the Canvas changes
            Path()
        }
        remember(canvasSize, points, endIndex) {
            path.reset()
            if (canvasSize != Size.Zero) {
                val startX = 20.dp.value
                val endX = canvasSize.width - startX
                val first = points.first()
                val scaled = scaledOffset(first, minX, maxX, startX, endX)
                path.moveTo(scaled.x, scaled.y)
                for (i in 1 until endIndex) {
                    val point = scaledOffset(points[i], minX, maxX, startX, endX)
                    path.lineTo(point.x, point.y)
                }
            }
        }
        val style = Stroke(8f)
        Canvas(
                modifier = Modifier
                        .padding(2.dp)
                        .background(Color.LightGray)
                        .fillMaxSize()
        ) {
            if (canvasSize == Size.Zero) {
                canvasSize = size
            } else {
                drawPath(path, Color.Black, style = style)
            }
        }
    }
}

Here is what the scaledOffset method looks like:

fun scaledOffset(
        offset: Offset,
        minX: Float,
        maxX: Float,
        canvasStart: Float,
        canvasEnd: Float
): Offset {
    val x = offset.x
    val y = offset.y
    // Keep track of the ratio's to maintain consistent aspect ratio
    val ratio = y / x
    val width = canvasEnd - canvasStart
    val nx = (x - minX) / (maxX - minX)
    val scaledX = width * nx
    val scaledY = scaledX * ratio
    return Offset(scaledX, scaledY)
}

That's really it. We have a full implementation of a PathTracer that can draw the path we want while keeping track of the endIndex we want to stop at.

Animations

All we really need to to in order to animate the PathTracer, is to control endIndex. For that we can define a transition.

// Transition Based Animation
val OffsetKey = IntPropKey()

fun pointsTransition(size: Int) = transitionDefinition<Int> {
    state(0) {
        // initial state of animation
        // number of points = 2
        this[OffsetKey] = 2
    }
    state(1) {
        // end state
        // all points being drawn
        this[OffsetKey] = size
    }
    transition(0 to 1) {
        OffsetKey using tween(
                durationMillis = 4_000,
                easing = LinearEasing
        )
    }
}

@Composable
fun TransitionsPathTracer(points: List<Offset>) {
    val definition = remember(points) {
        pointsTransition(points.size)
    }
    val animationState = transition(definition = definition, initState = 0, toState = 1)
    PathTracer(points = points, animationState[OffsetKey])
}

First we build a transitionDefinition with an initial state and a terminal state. Here we want the animation to start off by drawing 2 points and end with all the points being drawn.

Once we have the definition we use the transition @Composable to build the TransitionState.
We can use the IntPropKey to obtain the current endIndex while the animation is running.

All we need to do is to pass that to the PathTracer @Composable to start the animation.

Parsing coordinates

We need to parse the JSONArray of coordinates and pass that to the TransitionsPathTracer.

fun parse(): List<Offset> {
    // Use a better JSON parser
    val array = JSONArray(COORDINATES)
    val points = mutableListOf<Offset>()
    for (i in 0 until array.length()) {
        val coordinate = array.optJSONArray(i)
        val x = coordinate.optDouble(0).toFloat()
        val y = coordinate.optDouble(1).toFloat()
        points += Offset(x, y)
    }
    return points
}

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PathTracerTheme {
                Surface(color = MaterialTheme.colors.background) {
                    Tracing()
                }
            }
        }
    }
}

@Composable
fun Tracing() {
    val points = remember { parse() }
    TransitionsPathTracer(points = points)
}

Result

This is what it looks like.

Path Tracing