In this edition of ERA's Developer Blog, we'll be taking a look into the implementation details of the terrain renderer, the portion of ERA that brings our GIS data to life. In our first entry, the collection of pertinent GIS data was the main topic, so if you're interested in a bit of background for this entry, go check that one out!
The first step, as is the case for many projects, is selecting your set of tools and getting most of the boilerplate code out of the way. This includes scene initialization, camera settings, you name it. While I could get very technical about which engines/libraries best fit our needs, I'm going to stay high-level and assume the reader wishes to understand the concepts rather than copy-paste the implementation. With that being said, the instances where I need to use snippets will use Three.js.
Naturally, we should place the camera (or the subject we are looking at) right at the origin (0, 0, 0). At this stage, it would be reasonable to then realize that making an enormous world that can be traversed simply by walking is no easy task, as we can't just use real-world coordinates. Latitude-Longitude is too course of a resolution for games. Even scaling by a factor of 100 or a 1000 would be too inaccurate, and with any greater of a factor, you'll run into floating point precision errors.
With this in mind, we want to create a player-centric universe, where the world acts as a treadmill for the player. This solves the in-game coordinate system problem, as we can keep all objects within a reasonable distance from the origin. It also introduces the complexity of implementing this treadmill-like behavior of the terrain, which I will now describe with diagrams to provide a solid understanding of what we do in ERA.
When a player logs in, we retrieve their last location from our users database, which is stored in the form of (Row, Column). This format is what we use for ERA's location system, as our dataset has an origin of Row=0, Col=0 in the Pacific Northwest. Each territory increments Row and Column by 1. This makes querying for regions of territories very easy, which is exactly what we need to do when loading terrain into the renderer.
Let's say our example player was located at Mt. Rainier when they last logged off. Now that they're logging into the game, we get their RowCol coordinates, which are (1850.316317, 1850.123456). We then take the floor of the player's coordinates, then query our territories table for all territories within a given range. This provides us with a nice square of territories, as shown below.
In order to optimize terrain rendering, we use a Heightmap to reduce the number of vertices we send to the GPU each frame, resulting in a sheet of terrain represented in-game as one object.
Let's say the player now wants to move around. This is fine... until they get to the edge of the world. We should, of course, load more terrain as the player gets to the edge of the world, or better yet, we should add it before they even realize they're near the edge of the world! We query for the territories nearest to the player and add the new "sheet" to the scene. We will see an immediate decrease in performance, as we now have up to twice as much terrain in the scene as we did before.
This is where the idea of a terrain "treadmill" comes into play. We need to break up the heightmap into reasonably sized sections so as to 1) preserve the optimization that the heightmap provides and 2) allow us to add and remove terrain as the player moves, better known as "Chunking".
Now that we've optimized our terrain rendering, we can push the limits a little bit further. The size of the response to our terrain queries is pretty large, and with it comes technical burden on the renderer, so if we want to push the viewing distance, a good place to start is reducing resolution of the territories that are further away. Traditionally, this means decreasing the texture/model resolution, but when talking about heightmaps, we mean the number of elevation points per unit distance.
With that being said, we should query for additional terrain on top of what we already have rendered above. Going back to our RowCol system for storing territories, we can get every other elevation point based on our initial RowCol (1850, 1850). Querying our database for an even larger square region where row % 2 == 0 AND col % 2 == 0, we reduce the number of elevation points per unit area by 4, since we're working in two dimensions with RowCol.
We render this low-resolution "frame" around the main terrain we loaded previously, giving a far greater viewing distance with no perceived drop in quality to the player, and the frame terrain is too far away. Just as with the high-resolution terrain, these low-res chunks should be dropped and added as the player moves.
That's all for this developer blog entry. I hope you enjoyed this tidbit, as it has taken several months to work on this portion of the game alone. Please feel free to discuss on our subreddit at /r/EarthRevivalAct and mention what you liked, what you didn't, and what you'd like to see more of in the future!