DEV Community

Cover image for Jetpack Compose Desktop rendering performances
Gaetan Zoritchak
Gaetan Zoritchak

Posted on • Edited on

Jetpack Compose Desktop rendering performances

As Jetpack Compose is gaining traction, we plan to offer a specific implementation for our Kotlin Charting Library, Charts.kt.

Before starting to code it, we have to check if it’s a viable solution and if the rendering performances are already good enough.

This small post shows the resulting tests.

What to test?

The graphic needs for data visualization are relatively simple. You can do almost anything you want with rectangles, circles, and paths. Generally, rectangles and circles are directly available in the graphics API. You draw more complex shapes with paths.

So to test the rendering performances, we created a simple application that produces random particles to visualize on a canvas. Using different symbols and counts of particles, we can evaluate the rendering performance of Jetpack Compose Desktop.

We also implemented a JavaFX version for comparison.

Jetpack Compose Desktop

The code is available on GitHub: https://github.com/gzoritchak/compose-rendering

The test implementation uses the 0.4.0 version of JetPack Compose.

We focused our code on the rendering performances and not on the respect of Compose implementation patterns.

In particular, we limit the memory allocations. The particles are mutable classes that contain their position and speed. They are instantiated during startup and updated at each frame.

We then propose 3 different rendering of the particle’s position: a square, a circle and a diamond. The diamond is built through a path.

val particle = particles[id]
val x = xScale(particle.x).toFloat()
val y = yScale(particle.y).toFloat()
val color = Colors.Web.aliceblue.toColor()
when (renderingWith) {
    RenderingWith.Square -> drawRect(
        color = color,
        Offset(x, y),
        Size(10f, 10f)
    )
    RenderingWith.Circle -> drawCircle(
        color = color,
        radius = 5f,
        center = Offset(x, y)
    )
    RenderingWith.Diamond -> drawPath(Path().apply {
        moveTo(x, y + 5)
        lineTo(x + 5, y)
        lineTo(x, y - 5)
        lineTo(x - 5, y)
        close()
    }, color)
}
Enter fullscreen mode Exit fullscreen mode

See the resulting performances on my MacBook Pro (late 2013 2,3 GHz Intel Core i7).

Up to 10 000 particles, the rendering performs at 60 FPS using squares and circles. It then fell to approximately 30 FPS for 30 000 particles and 8-9 FPS for 100 000 particles. The first decrease with paths (diamonds) appears at 10 000 particles with a not stable 30 FPS. We can also observe some freeze.

Compose 3000 10 000 30 000 100 000
Square 60 FPS 60 FPS 28 FPS 8 FPS
Circle 60 FPS 60 FPS 31 FPS 9 FPS
Diamond 60 FPS 30 FPS 10 FPS 2 FPS

Now let’s compare these performances with a settled framework.

JavaFX/TornadoFX implementation

We implemented a JavaFX version of this test application to have a comparison point.

The code is available on this GitHub project.

We render the particles through the Data2viz library, which is the current implementation for our charting library. Under the hood, the Data2viz library uses a JavaFX canvas.

Here is a screencast of the results using the same hardware:

We can observe that the rendering speed is double for squares and circles. The difference is weaker when we use paths.

JavaFX 3000 10 000 30 000 100 000
Square 60 FPS 60 FPS 60 FPS 20 FPS
Circle 60 FPS 60 FPS 60 FPS 20 FPS
Diamond 60 FPS 37 FPS 13 FPS 3 FPS

Conclusion

Even if the performances are not as good as their JavaFX counterpart, they are already good enough for many visualization use cases.

Jetbrains builds Jetpack Compose Desktop on solid foundations. The works they are doing on integrating Skia in a Kotlin context is also promising for new usages (Swing, Headless).

Top comments (4)

Collapse
 
romainguy profile image
Romain Guy

You may be able to improve the performance of the diamond case in Compose by building a single path that doesn't depend on x and y, and instead position it using a translation transform on the Canvas.

Collapse
 
gz_k profile image
Gaetan Zoritchak

@romainguy Thanks for the proposal.

But applying a translation leads to another drop of FPS.

New implementation:

RenderingWith.Diamond -> drawPath(Path().apply {
    moveTo(0f, 5f)
    lineTo(5f, 0f)
    lineTo(0f, -5f)
    lineTo(-5f, 0f)
    close()
    translate(Offset(x,y))
}, color)
Enter fullscreen mode Exit fullscreen mode

Results:

Compose 3000 10 000 30 000 100 000
Square 60 FPS 60 FPS 28 FPS 8 FPS
Circle 60 FPS 60 FPS 31 FPS 9 FPS
Diamond 60 FPS 25 FPS 8 FPS 2 FPS
Collapse
 
romainguy profile image
Romain Guy

Repeating what I mentioned on Slack: my suggestion was not to apply a translation to the Path object itself. Instead create one instance of the Path, and before drawing, translate the Canvas: canvas.withTranslate { drawPath(…) } or equivalent.

Thread Thread
 
gz_k profile image
Gaetan Zoritchak

Sorry for the mistake; I read your message too fast.

I tried your suggestion with one path and moving the canvas before drawing it. It’s still a little bit under the first implementation performances but more stable (no more freezing).

RenderingWith.Diamond -> {
    canvas.translate(x,y)
    drawPath(diamond, color)
    canvas.translate(-x,-y)
}
Enter fullscreen mode Exit fullscreen mode