At some level of UI complexity, most developers start wondering whether they’re better off rendering their
Scene using a JavaFX
Canvas object, rather than loading everything in as a
Shape on a layout
Especially if your UI is starting to get laggy or unresponsive.
When you load a
Shape or a
Node onto a
Pane, JavaFX maintains dozens of properties associated with the shape’s bounds, transforms and style properties. That creates an overhead as your scene gets more complicated.
It’s really convenient to be able to access all of those, but at what cost?
The canvas trades all of that convenience for the performance benefits basically painting everything as a single flattened, 2D texture.
So, if you’re noticing some lagging in your UI and you’re trying to improve performance, using a
Canvas might be the way to go.
How do I choose between them then?!
Actually working out when you need to switch between using a
Pane and a
Canvas can be really tricky. To test each, I designed three challenging use cases to test the performance
Canvas and the
Pane in varying combinations of computational and rendering requirements.
|Official name||Description||Computation between frames||UI Frame Rate test:|
|Object Transforms||Create a bunch of objects and move them around a bunch and see how the UI deals with it.||Low||Every pixel is drawn several times (many layers of objects) per frame.|
|Collision Detection||Create slightly fewer objects, but make them test whether they intersect with other objects.||Moderate||Every pixel is drawn at least once.|
|Gravity swarm||Create a lot of objects – I’m talking millions.|
Apply forces to every object, like gravity.
|High||Most pixels don’t need to be drawn every frame.|
Memory usage: As a bonus, because I’m creating so many objects, these tests also make a really good test for how memory-efficient the
A sneak peak at the results
In every performance test, the
Canvas renders objects more efficiently, requiring less memory and outperforming the
Pane when the scene becomes too complex. At high computational complexity, the
Canvas object also throttles the refresh rate, keeping the frame rate consistent at 30 FPS for longer.
But that’s not to say the Canvas is always better. Both the
Canvas and the
Pane objects, and their sister objects in the Prism rendering layer, maintain knowledge of which areas of their node have been modified and need to be recomputed for rendering. That means if your UI is quite simple, JavaFX won’t waste performance on recomputing the contents of your scene every frame.
On computers with a reasonable processor, you may have to make a scene very complex (usually tens of thousands of objects) before a difference between the
Pane becomes noticeable.
Whether to use a
Pane or a
Canvas depends completely on what you want to use them for, how much convenience you need and – sometimes – on where you think your users are going to deploy your program.
How the tests work:
The tests are designed so that the
Pane attempt to render the same Scene, but produced in different ways.
Each test will measure the frame rate of the
Pane objects. The tests increase in complexity from a simple scene (where neither
Pane struggle) to a complex one (where both struggle).
In each case, I’ll also compare the length of code required to generate the sample.
Alright then, on we go!
This first test is all about what it looks like when your scene gets too complex. You might not be doing any computation, but you’ve just gone and added too many elements.
To give you an idea of just how many, the test varies from 4,000 to 400K.
There’s this idea floating around in JavaFX forums that if you’re using shapes in your user interface, it’s much more convenient to use a
Pane because all of your transforms (like translation and rotation) are already baked into the
Shape objects you’re using.
Canvas itself has an amazing array of methods to draw basic (and complex) shapes, so as long as you’re happy creating an object to store their position, you’re basically golden.
In this test, I have literally just created a pre-defined number of circles, in exactly the same way (radius 10px, random color) in both a
Pane. Then, I give every particle a random movement vector and update their position every frame.
Obviously, I’ll use a slightly bigger window and more circles, but bear with me.
OK this looks good…
What I’m interested in testing here is the idea that using a
Pane is much more concise in code. The
Canvas has a lot of built in methods to draw circles, rectangles and SVG images, which we’ll also make use of.
Overall, there’s almost no benefit to using the
Pane in terms of code length, because the majority of the complexity comes from the need to create custom behaviour for our particles.
Each particle (
Pane) needs to have the capacity to calculate attraction and movement in addition to position, as well as to bounce elastically off vertical and horizontal boundaries.
In this case, the particle extends the
Shape object. That allows us to keep the convenience of the
Circle shape, but add custom behaviour.
The only ‘extra’ properties our
Particle needs compared to the
Particle are its
position, and a
Color, because the
Circle object contains both position and color already, so it’s not needed for our
Particle. If we were creating more complex shapes with transforms, these would need to be added too.
That’s the benefit of using a custom object though – only adding the properties you need. We have no use for rotation or scale here, so it’s not necessary to include them.
OK, this is probably what you’ve been waiting for. Basically I’m going to ramp up the number of circles until the
Pane start to struggle. JavaFX isn’t necessarily smart enough to know that layers of objects can’t be seen, so it tries to render everything I’m giving it.
I ran the test between 4K and 400K circles, at which point both the
Pane started to struggle quite a lot. I’m running these on a i7-1065G7 processor, which is OK but by no means award winning.
Below 10,000 nodes (green), JavaFX’s artificial framerate limiter means you can’t notice the difference between the
Pane objects. However, above 10,000 (yellow) the
Pane begins to struggle.
Pane object doesn’t outsource as much of its rendering to the GPU, so even when the limitation is to do with rendering, changes to a
Pane are calculated and rendered mostly on the CPU.
Looking at the graph, between 25,000 and 100,000 actually looks really interesting. While the Pane continues to struggle, the
Canvas seems to be able to compensate for heavy render load in ways the
What’s interesting is that the limiting factor here is rendering performance. The actual calculation-based CPU load is really low because all we’re doing is calculating a simple vector addition (we’re moving it, but by the same amount every frame).
Somwhow, the Canvas’s internal optimisations means it stabilises at 30 FPS for a really significant amount of time. My suspicion is that this is something to do with the internal buffer processing being culled when it falls behind.
If you’re interested in finding out more about the Canvas, please check out my write up, which goes into a lot of detail.
To ramp things up a little, we can increase the computational complexity a little by including the need to not only render objects, but also detect whether they collide with other objects on the screen.
Here, I’ll render a background grid of a defined size, and create foreground objects, which will move across the grid. Where a foreground (coloured) object intersects with the grid, we’ll color the grid in the corresponding color.
Once this scene gets pretty congested, it’s actually fairly fun to look at.
Again, to test the idea that code is more concise when using shapes on a pane, I looked at the number of lines of code required to generate the scene in each case.
Unlike in the previous example, all of JavaFX’s
Node objects have inbuild
Bounds, which we can use to work out whether two objects are intersecting, but nothing we’ve created on the
Canvas has that. For our
Canvas implementation, we need to build in some of that functionality.
The cost is 20 lines of code. That gives us an
intersects() method, and maintains a memory of where the object is, and its dimensions.
Again, we’re comparing here the efficiency of each implementation in dealing with a scene that gets more and more packed. Specifically, what I’m doing is decreasing the grid size of the background, meaning each foreground rectangle has to calculate more and more intersections.
Looking at the graph, bear in mind the units are million collision calculations per second.
In this case, we actually don’t need to do much processing before the
Pane begins to suffer.
Above about 800K per-frame collision-calculations, the achievable framerate for the
Pane drops well below 30 FPS. Our
Canvas implementation is stable until about 2.5 million.
Above 2.5 million, the
Canvas begins to suffer too, but by this time the
Pane is basically clawing its way to render hell, whatever that is.
I’ll just say again that this obviously depends on where you’re running your code. I’m running it on a i7-1065G7 processor. That’s lightyears ahead of a Raspberry Pi, but nowhere near most desktop processors.
It can be more convenient to have JavaFX maintain the transforms applied to objects indefinitely. When arranging nodes on a
Pane, each node comes with a
Bounds object, which represents the location of the node in the parent
Pane, as well as the wider
Scene. The node also stores handy properties like opacity, which you can modify.
All of that means that you can automatically calculate intersection, move objects, resize them and perform transitions like fading.
800K collisions is quite a lot of collisions to have to detect in a frame… In fact, that’s 900 objects all detecting collisions between each other before you begin to notice the difference.
I prefer the
Canvas when I’m making precise and controllable changes to objects, like during game development and custom charts / graphs. The canvas object gives you pixel-specific control and you get to choose which object properties you want to maintain.
You can also simplify or complicate collision detection to suit the accuracy you need. Whereas, with Shapes on a
Pane, you are ‘stuck’ with the implementations provided.
Both of the above examples have demonstrated examples where near-enough every pixel is re-drawn every frame. This tested JavaFX’s ability to balance CPU and GPU load (basically only the
Canvas takes advantage of this).
Changing every pixel multiple times per frame is relatively rare, I’d imagine.
So, in this example, well draw on a relatively small proportion of the canvas, but we’ll massively increase the computational complexity.
One of the best ways to increase computational complexity is adding trigonometry, square roots, and normalisation. This way, you don’t need many points, because each frame every particle needs multiple, complex adjustments based on forces, position, and velocity.
For this example, I’ll draw each particle much smaller here (radius 2 px). That way, I can draw hundreds of thousands of particles but with a relatively small pixel footprint. In fact, the overall effect is going to be to produce a swarm that focusses around a single point.
As an added benefit, I think it looks pretty damn cool, which is always an indication it’s a well-designed test. Perhaps.
In the final test, we’ll repeat the process of testing to what extent shapes on a pane are more concise. Here, we have custom behaviour that extends beyond what JavaFX provides with its default shapes, s we’ll need customisation in both cases.
In this case, it’s actually slightly cheaper to create the objects in our
Canvas implementation, because we only need to add them to a list we’re maintaining. In the
Pane implementation, we need to add them to a list, and to the
Other than that, the differences are relatively minor, with most behaviours being implemented in similar numbers of lines in each case. In the end, the
Pane steals it by 9 lines.
For a final time, let’s look at the gravity swarm as it gets bigger and bigger. In this case, both implementations hold up really well until about 10,000 particles in the swarm.
Above this threshold, the
Pane begins to render more slowly, slowing down until just below 100K, at which point I stopped testing it.
Running on an i7-1065G7 processor, the
Canvas doesn’t lose any pace below 40K, at which point it begins to fail at a similar rate to the
Pane. I eventually stopped testing it at 400K.
I think generally for me this shows two things Firstly, you actually need quite a lot of nodes before your scene starts to slow down, but for the third time, the
Canvas outperforms the
One extra thing I haven’t mentioned, which you’re probably already thinking, is about memory efficiency. The key to this is that the
Canvas flattens all of its drawn objects into a single texture. This is orders of magnitude more memory-efficient.
This is basically true generally across all of these tests, but more so here because the limitation is node number. It’s not on computational complexity, like in collision detection and gravity simulations.
Shape objects and their bounds are taking up 20% of the heap memory, which – by the time you get to 100,000 objects, comes to about 600 MB (that’s 120 MB for our shapes and bounds). The Prism rendering layer (which is JavaFX’s carbon-copy scene for rendering purposes) is taking up another 18%).
So, about 240MB for rendering…
Canvas method, the heap is about half the size (even smaller with efficient garbage collection), at 150MB, and consists mostly of the byte arrays the Canvas uses to shuttle rendering instructions to the GPU. These account for about 30 MB. Our custom Particles and Points come to about 9 MB.
In total? 39 MB.
Two major conclusions stand out to me from all of these tests:
- When you need performance, always go with the
- Code is not that much more concise with a
Paneimplementation (but it depends)
Here are some of my comments on each:
In both memory and rendering performance (frame rate), the
Canvas outstrips the
Pane in every challenging scenario. In high CPU scenarios, the
Canvas‘s more lightweight approach means it can process more objects more quickly.
On top of that, in any scenario with heavy GPU load, the
Canvas also compensates for its inability to process all rendering instructions by ditching the rendering buffer when it’s running behind.
This means it maintains a frame rate of 30 FPS for even longer before it begins to struggle.
Code doesn’t have to be that much longer when you’re implementing a Canvas-based solution to a busy UI.
There are specific functionalities that every
Node has that can be really useful:
- Calculating intersections
- Storying transforms like rotations and translations
- Animations (although not likely to be efficient)
- Mouse / Touch / Gesture events
One thing I did notice, however, was that once the UI starts to struggle, JavaFX starts trying to free up time to process its UI changes by batching up things like event handling.
That means if your UI is very busy, and you’re also trying to use event handling, this lagging will only be made worse.
At the end of the day, I think there are specific situations where you might want to use both of these objects. However, I’d reserve using a standard Shape/Node/Pane set up for what I’d call a “Standard” UI.
Dashboards. Music players. UIs where CSS styling is important.
Ultimately, I enjoy using a
Canvas for games and custom implementations, more because it feels natural inside a game loop. But the
Canvas can also be used to recreate many of the features of a busy UI with a smaller memory footprint and lower processor use.