When I started working on Ardenfall, I quickly realized I would need to develop a world structure, and world streaming. Here’s a dive into the technical bits on how Ardenfall loads its world! If you’re more interested in the project itself, then the latest devlog may be more to your liking. Regardless, lets continue!
When I started working on the world structure, I initially planned on allowing cells to be any shape, as well as having multiple “layers” (cells overlapping cells). This quickly complicated things, and I later decided to boil it down to this data structure:
- World - Map - Cell - Cell - Map - Cell - Cell
WorldData, MapData, and CellData are all Scriptable Objects. WorldData holds any information that relates to the entire game world, where each Map holds a list of cells.
These cells are essentially a flattened 2D array of cells, and each of these cells are the same size. This results in a cell grid, which makes streaming very simple. I load a 3×3 grid around the player.
One key focus was making my world file structure git friendly, allowing different people to be working on different areas at once without conflicts. To do this, data relating to a cell (terrain, CellData, scene, foliageData, preview, etc) is put into individual folders, to allow level designers to pick and choose which cells they wish to push and pull. Currently this is done manually, but I plan on adding an interface that will hook into git and make it so developers can visually discard changes / push changes on individual cells.
Loading a 3×3 region around the player is good, but the player needs to be able to see much farther than that: trees, mountains, even towers! This is done by generating “Distant Cells”, which are prefabs that are automatically built.
When scanning a cell scene, the builder looks for any objects with a “Distant Cell Adder” component. Once it finds it, it strips away all objects / components that are not MeshRenderers and MeshFilters, and packs it together into a prefab. (It also makes sure to disable shadows)
In other words, level designers simply “Tag” the objects they wish to be seen in the distance by adding a component, and the generator does the rest, including stripping non visual content. These prefabs are then automatically streamed in the distance around the player.
I also have a special terrain generator, which converts the terrain for that cell into a low poly mesh, and also converts the terrain’s splatmaps into a texture. This texture is packed into an atlas, to build a huge single texture/material for all terrains on that map.
At the moment, this low poly mesh does not correctly connect to the loaded-in terrains next to it, resulting in seams. Later on I plan on fixing this by making the terrain edges automatically align to the terrain meshes during runtime, then unalign once a neighbor terrain is loaded in. This will be interesting to implement.
Another thing I generate is a preview texture: this is a little picture that is taken for each individual cell, which is then combined into a nice big map texture. I use this for the editor, as well as the ingame map.
Cell Prefabs versus Cell Scenes
Each cell, map, and world has a scene. When I load multiple cells in the editor, it loads these scenes additively, and the same goes for during runtime via world streaming.
However, one helpful feature I’ve implemented is the ability to automatically convert all cells into prefabs, and to use those instead of scenes in a build. This is incredibly helpful, as it speeds up builds from 3 hours to 3 minutes. Of course, exporting prefabs takes a while (although not even close to 3 hours), but you only need to re-export whatever cells you’ve edited recently. This makes testing builds incredibly fast, but it also isn’t the best for final builds, since not using scenes means all of those prefabs are loaded into memory at startup. Also, prefabs cannot have lighting data packed in (at least by default). Thus, it is best to use Scenes in a final build.
Streaming in Game
Streaming the world during runtime is ridiculously easy. Load the world scene, load the current map’s scene, then load the cell scenes around the player. Whenever the player walks into a new cell, unload the cells behind them and load the new cell scenes. And of course, load/unload whatever distant cell prefabs need to loaded / unloaded.
In short, having a basic world streamer is surprisingly simple. However, I will point out a warning: my game, Ardenfall, is not exactly heavy on the texture/model side. In other words, even the most complex area isn’t very complex compared to modern AAA games. If you’re looking to build a massive open world game with a huge amount of content, having simple square cells may not be for you. However, I hope this post served as a helpful example of one way to build world streaming technology. If you’re interested in checking out Ardenfall, be sure to follow me on twitter, and join us on discord!