PeaksAndValleys is a sample WebGL application developed for the BlackBerry® platform. It is primarily a learning resource for developers implementing pure WebGL along with additional HTML5 gaming concepts. It has also been open-sourced on Github because, well, we love open source. To learn more about WebGL and HTML5 gaming, be sure to join our webinar on November 15th. If you’ve already developed an HTML5 game, join us on November 16th for the BlackBerry Got Game Port-a-Thon with some pretty awesome prizes – including trips to the Game Developer Conference 2013 in San Francisco, CA.
Now with 4500% more unique peaks and valleys.
On November 2nd, PeaksAndValleys 2.0 was uploaded to Github with a number of improvements. In the first release, we relied on a static map that was 150 by 150 vertices (22,500 total vertices.) When we’re talking about a map, we’re just referring to a grid of vertices where each vertex has a specific height.
Bird’s eye view of a 33 by 33 vertex grid (map); each vertex has a specific height associated with it.
These 22,500 vertices were all generated during startup with randomized heights. It was a good initial implementation, but subject to a number of limitations:
- Maybe we want a fixed / persistent world to traverse.
- We can’t expand the map much more due to performance reasons.
- If the player runs in any direction for long enough, they will hit the edge of the map.
Let’s take a look at each of these individually.
Fixed / Persistent World
One of the more common approaches to this problem is the use of a static source of data; in our case, a height map. This is the approach opted for in PeaksAndValleys 2.0. The following is an example of what a height map might look like:
Sample height map (originally 1024 by 1024 pixels).
Each pixel represents one vertex (or point) of the map, and the RGB colour of that pixel is used to determine the height of each vertex. In the case of PeaksAndValleys, the lowest height occurs at pure black (RGB = 0x000000) and the highest height at pure white (0xFFFFFF).
Though the height map is only 1024 by 1024 pixels, we can apply a scaling factor to extend the map across larger distances in our WebGL world.
Depending on how smooth the source image is, we may get areas where there is a very low region next to a very high region. To prevent drastic changes in terrain, a smoothing algorithm was implemented that averages the height of each pixel with that of its neighbours.
And with this, we address the fixed / persistent world by using a static data source. We can quickly see how multiple height maps and randomized starting locations can expand the versatility of this approach.
As noted, the first release of PeaksAndValleys contained a static 150 by 150 vertex area and expanding to a larger number of vertices would have an impact on performance; so then what good is a 1024 by 1024 height map?
In our case, we’re actually only loading a 121 by 121 region at any given time. As the player traverses the terrain, we are continuously reloading the surrounding region data (with the player at the center.) This means that as the player moves around, terrain in their vicinity is continually loaded from the height map.
There is a lot of processing that needs to go on in order to keep loading terrain — so much that if we do this processing on the main application thread, we’ll see a drop to roughly 30 frames per second. This is where Web Workers come in, for which the implementation can be seen in GLTerrainWorker.js.
A Web Worker allows a separate thread to perform actions without interfering with our main application thread. Our main application thread is where all of our interactions and rendering are performed, so if we can minimize the work being done there, we can improve our frames per second and responsiveness.
Web Workers aren’t without their limitations. Specifically affecting us, we cannot:
- Access the DOM. This eliminates WebGL rendering directly on the Web Worker.
- Pass objects with functions between the main application thread and Web Worker.
These two limitations mean that while we can perform our processing on a Web Worker, we actually need to pass the processed data back to our main application thread in order to update our rendering objects. We do this via the onMessage listener and postMessage initiator to initialize our Web Worker with the data it will need, and then pass subsets of that data at various intervals. You can see this initialization and rendering update performed in GLTerrain.js.
By offloading this processing to a Web Worker, we can continuously load more data off the main application thread, and only pass back the final data results to be assigned to our renderers. The end result is increasing our frames per second from roughly 30 to 50-60 frames per second; a huge win. To learn more about Web Workers, the following HTML5 Rocks tutorial is a great starting point.
The final issue we had with our 150 by 150 static area was that when you run to the edge of the map, you hit the border. Loading data from a larger 1024 by 1024 area certainly extends the time before the player hits the border, but inevitably they will if they keep moving in one direction.
To counter this, the application continuously loads an area of pixels surrounding the player. As you reach any of the edges, data from the opposite side of the map will we used to populate that area. This produces a continuous terrain, even when travelling near the edges of the map.
Red cross represents the location of the player, white area represents the data we’ve loaded.
By wrapping the data that we load as we get close to the edges, we can in essence allow the player to run forever. You may notice that this image isn’t intended to be seamless (i.e. when wrapping around, the colours at the borders do not match up or flow together). This can be overcome by using a height map that is already seamless (i.e. it has already been processed to ensure that the pixels on the north side match those on the south, and those on the east match those on the west). In the case of PeaksAndValleys, our smoothing algorithm was implemented to wrap around the image when necessary (i.e. at the edges), thus turning non-seamless images into height map data that is seamless.
To obtain usable height map data, we must:
- Read a source image; in our case 1024 by 1024 pixels.
- Perform height calculations.
- Calculate the normal (perpendicular vector) of each point.
- Calculate texture coordinates.
This also means that we are re-calculating data on every load just to get the same result as our previous run — a very inefficient approach.
To address this, a Node.js preprocessor tool was created that reads in an image, along with some parameters, and generates a json file with the resulting vertex, normal, and texture data required by our WebGL application. On a 1024 x 1024 image, the resulting file is roughly 50mb in size. Originally, this file was closer to 120mb, however by truncating decimal points to 5 significant digits the overall size was reduced.
As a result our application doesn’t actually process any data on load. Instead of feeding in an image, we are now feeding in the output json file with all calculations already in place. That being said, we are still loading a 50mb file which does take some time, but now we’ve managed to cut the loading time closer to 5 seconds on a mobile device.
Using preprocessed data also minimizes the amount of work the Web Worker has to do, as it will simply retrieve the appropriate data based on the user’s location and pass that data back to the main application thread.
Ultimately, PeaksAndValleys has seen a large number of improvements in the terrain implementation. From preprocessing / generating seamless height maps to leveraging Web Workers and minimizing strain on the main application thread, we now have a much more versatile world.
There are still some issues such as terrain popping into view as you move about. There is also room for optimization by only loading a cone of data in the player’s field of view (as opposed to a square in all directions around the player). These are topics that will be addressed in the next release.
For more information on this project, be sure to check out the Github release. Feel free to leave questions there, or reach out to me directly on Twitter® via @WaterlooErik.