DevLog #005: Streaming Chunks

Part of a series of posts in which we write a voxel engine from scratch for Android.

If you’ve been following this devlog, you’re probably beginning to see just how much data we need to throw around to generate block-based terrains, and the more technically inclined reader will know already that this is just not scalable. We cannot possibly load the entire world into memory at once.

Even without infinite terrain, let’s say we have 24 x 24 Bigchunks in the map, each containing 8 Chunks, each containing 4096 blocks; that’s 18,874,368 blocks total. Now just using our Lookup-Tables to store blocks as short integers at 2 bytes each, that’s 37MB RAM, but we also need to store visible blocks as 3D models (our batched meshes). These are heavily optimized with our face-culling magic, but let’s say on average, a block has 2 of it’s 6 faces visible – that’s 4 triangles (made of 3 (short int) indices each) and 8 vertices (8 floats each for position, normal and uv); you can add another 453MB for indices, and 4.8GB for vertices.

That’s a worst-case scenario, and in practice there will be a large majority of blocks that aren’t visible at all, but it illustrates the need for Chunk Streaming – which we will use to load and unload chunks into memory on-the-fly as we need them.

Active Chunks

We can fix this by having a certain number of Active Chunks surrounding the player, which can be unloaded, moved to a new position in the world and reloaded with new terrain – essentially recycling a smaller number of chunks to ensure visible parts of the world always get loaded without increasing the memory usage over time.

We achieve this by sending out ‘probes’ from the player’s position to check if any new chunks need to be loaded in, and if so, we unload the Active Chunk that is furthest away (preferably behind the player) and move it to the new position.

The green cubes represent points at which we check for unloaded Chunks

Caching

Every time we unload a Chunk, we can cache its Lookup-Table in memory, or write it to disk if the memory cache decides to overwrite it, since the memory cache will need to be of limited size.

If an Active Chunk needs to fill its Lookup-Table, it will first check the memory cache, then the disk cache; and if it finds none for that area, it will generate new terrain using our noise function.

Heap Size (and Good Behavior)

The problem with memory usage on Android is two-fold; first we have the issue of device fragmentation, which means there are many devices out there with very low available RAM – and in order to support as many devices as possible, we’ll need to reduce draw-distance (and Active-Chunk count) where necessary.

The bigger problem is one of Heap Size – an arbitrary limit on the maximum amount of memory each app can use. It doesn’t matter if your device has 1Gb RAM, your app will not have 1Gb RAM to play with. Not even close – and this maximum Heap Size is set by the device manufacturer – with a minimum of only 16MB.

For example, a fairly old Samsung J6 we’re testing on allows us 96MB, unless we ask for a larger heap in the manifest, in which case we get a generous 268MB. It’s considered ‘good behavior’ to limit ourselves to the smaller heap if we’re targetting this phone – but every phone is different.

The answer is to set our own limit to far below what we are given, and to optimize as much as possible.

Memory usage during initial building of the map. The dotted-line is our allocations, which drops off after we free our temporary Blocklists. We’re well below our heap size, but we can do better.

That’s all for now! In the next post, we’ll be adding water to our terrains, which is awesome.