1) Introduction
I'm currently working on a strategy game in JavaFX and decided to use isometric projection for rendering the graphics. During development I encountered some performance issues specific to JavaFX 1.2. It was expensive to add many nodes in a single Group and it was furthermore expensive to add/remove a lot of nodes from a Group.
The first issue was resolved by having more levels of Groups. Instead of 1 Group containing all the nodes, I added several layers of Groups. Each Group in the bottom layer would then only hold a small portion of all the nodes.
Resolving the second issue was done by using a quadtree to quickly decide which nodes should be added and which nodes should be removed from the scene graph. This way I only had to add/remove a small portion from the scene graph during the render pass.
Of course, a few weeks ago, Oracle released JavaFX 1.3, which magically solved all these problems by itself. Rendering many nodes in one Group no longer was a problem. However, having the quadtree structure still has a major advantage. You only need to render the nodes that are actually visible on the screen. This should eat a lot less memory then when you would render all the nodes in your world. So, I'm still going to show you how I've done the quadtree implementation.
2) Brute force rendering
Before going over to using quadtrees, I will first show you the easy way: rendering all tiles in a brute force manner. My game world consists of a series of tiles laid out in a two-dimensional grid. Each tile has an X and a Y position to store it's position within that grid. Rendering brute force means that we will render all the tiles in the game world, even when they are not visible on screen.
var scene : Scene = Scene {
width : 1000
height : 800
content : BruteForceRenderer {
tilesWidth : 512
}
}
Stage {
scene : scene
}
def TILE_WIDTH = 32;
def TILE_HEIGHT = 16;
public class BruteForceRenderer extends CustomNode {
postinit {
var numberTiles = Math.pow(tilesWidth, 2);
tilesGroup.content = [
for (i in [0 .. numberTiles - 1]) {
var x = (i / tilesWidth) as Integer;
var y = (i mod tilesWidth) as Integer;
Polygon {
points : [ TILE_WIDTH / 2, 0.0, TILE_WIDTH, TILE_HEIGHT / 2, TILE_WIDTH / 2, TILE_HEIGHT, 0.0, TILE_HEIGHT / 2 ]
translateX : TILE_WIDTH / 2 * (x - y)
translateY : TILE_HEIGHT / 2 * (x + y)
fill : Color.GREEN
}
}
];
}
public-init var tilesWidth : Number = 64;
var tilesGroup : Group = Group {};
override protected function create () : Node {
tilesGroup
}
}
The
BruteForceRenderer
is a CustomNode
that contains one Group
in which all the tiles are rendered. The tilesWidth
parameter defines how wide our grid must be. A value of 8 for instance means that we will have a grid that contains 64 tiles in total.The tiles themselves are made up of a
Polygon
with a green color. They are added one by one into tilesGroup
. The order in which the tiles are layed out is from top to bottom and from right to left. The following image, which is a render of 9 tiles, probably clarifies this a bit. The numbers between the brackets are the x and y coordinate of the tile that is being rendered.The main advantage for using the brute force approach is that the code remains very simple to understand. The disadvantage will become clear when you start raising the
tilesWidth
parameter. Even though they've made lots of performance improvements in JavaFX 1.3, it's still becoming a bit sluggish for instance when implementing mousedragging to move around the world.3) Using a QuadTree
With brute force rendering, we keep track of all tiles whether they are visible on screen or not. However, we should only render a tile when it is actually visible on the screen. On the next image we can see the tiles in green and our screen in blue.
This clearly shows how brute force rendering is done: all tiles are visible, even those that do not fall within the blue rectangle. To solve this, before rendering a tile, we could check to see if it's bounds fall within the screen view or not. The easiest way to do this is to check all the tiles one by one. However, a more elegant solution is to use a quadtree. A quadtree allows us to quickly discover which nodes are actually visible on screen without having to check every possible node.
The process is as follows:
- divide the world space into four equally sized squares
- for every square, check if it's bounds intersect with the screen bounds
- if they don't intersect: skip this square
- if they do intersect: divide this square into four equally sized squares and repeat step 2
We keep dividing until we reach a specific depth. When this depth is reached, we will just individually check all tiles that are still available and render those that fall within the screen. Again, I'll use an image to make this more clear.
Step 1 divides the world into 4 squares. We check the bounds of square 1 and see that it intersects with the screen bounds (again the blue rectangle). In step 2 we divide this square again into 4 squares and check the bounds of square 1. We see that it doesn't intersect with the screen, so we check square 2. This time we have an intersection. So again, in step 3, we will divide this square number 2 into 4 equally sized squares and repeat our intersection checks. We can clearly see that square number 1 doesn't intersect, but square number 2 does.
At this moment we have reached our maximum depth of 3. So instead of dividing this last square into 4 smaller squares, we will go over the nodes that are contained in this square and perform the bounds check on each of these nodes. If a node intersects it will be added to the scene graph, otherwise it will be skipped. This is shown in step 4.
Below is a sample of this application rendering a world containing 256 by 256 tiles. You can also download the code as a NetBeans project.