Recently I began to optimize voxeling’s usage of WebGL buffers. The goal was to get rid of render performance bottlenecks so I could further increase the draw distance. In real life humans can see about 2+ miles away before the curvature of the earth drops things out of view. The game engine was previously only able to draw out to roughly 700 feet before stuttering, but the latest code can draw to about 1500 feet. It’s not as far as I’d like, but it’s still a big improvement.
Now let’s talk technical details. First I need to outline some basic facts and then I’ll compare the old drawing logic with the new.
- A voxel cube consists of six faces, each of which can be drawn with a different image texture.
- The world is made up of “chunks” of voxels. Each chunk consists of 32x32x32 voxels.
- We need at least three WebGL buffers to draw our voxel world: One buffer for the cube points/vertices, another for the surface normal at each point, another for the texture coordinates corresponding to each point. Let’s refer to these as a “buffer tuple”.
- A draw distance of 1 means we draw 1 onion layer of chunks around the player’s current position. So a draw distance of 1 results in 3x3x3 total chunks worth of voxels being drawn.
Before the optimizations
- Each chunk had a single buffer tuple associated with it
- We pushed points, normals and texcoords for all textures present within a chunk into this buffer tuple
- For each texture present within the chunk we kept track of where the texture’s points+normals+texcoords began in the chunk’s buffer tuple
- The more chunks we drew, the more tuples there were to bind and draw since there was 1 buffer tuple per chunk
Rendering looked like this:
foreach chunks as chunk bind chunk.bufferTuples foreach chunk.textureOffsets as texture, offset activate texture render chunk.bufferTuples starting at offset
As the number of textures used per chunk grew, and as we increased draw distance to draw more chunks, the number of render calls went up dramatically.
Here’s the old equation for calculating the number of render calls: ((2d+1)^3) * t
Let’s assume t = 6, or an average of 6 textures present within each chunk. And assume d = 3, or a draw distance of 3 chunks in each direction away from the player. Using those numbers results in 2058 render calls! It became apparent at draw distances beyond 6 that this was simply too many calls for the CPU to make without slowing down the framerate.
The upside to this setup was that we could re-mesh and draw changed chunks very quickly since each chunk had it’s own dedicated buffer tuple. It’s necessary to re-mesh and re-draw as you or a friend create or destroy voxels within the world. That’s how the change gets shown on-screen.
After the optimizations
- Rather than assigning a buffer tuple to each chunk, we now assign tuples to each image texture. Each texture has two buffer tuples: one containing the data for nearby chunks and one for far chunks.
- Nearby chunks refer to the 3x3x3 cube of chunks surrounding the player’s position.
- The number of far chunks varies with the draw distance setting.
- We update near chunk buffer tuples more frequently than far chunk buffer tuples to prevent framerate stutters.
Rendering now looks like this:
foreach nearbyChunkTextures as texture bind texture.bufferTuples activate texture render texture.bufferTuples foreach farChunkTextures as texture bind texture.bufferTuples activate texture render texture.bufferTuples
This new setup uses far fewer WebGL buffers. Regardless of the draw distance setting, the number of WebGL draw calls now has an upper limit of 510. Recall that each image texture is assigned two buffer tuples and the game engine supports up to 255 textures.
In the bullet points above I mentioned that the buffer tuples for near and far chunks are updated at different frequencies. The engine updates buffer tuples like so:
- A voxel in the world is changed
- The chunk is re-meshed, which is the process of converting the voxel cube data into triangles for drawing
- The mesh data is added to a queue
- Every 100 milliseconds we pull the updated mesh data for near chunks from a queue and update the appropriate buffer tuples
- The updated nearby chunks show on screen when the next frame is drawn
- Every 1 second we pull the updated mesh data for far chunks from a queue and update the appropriate buffer tuples
- The updated far away chunks show on screen when the next frame is drawn
This is what it looks like after the code changes with a draw distance of 10:
(As you can see, the haze is a bit aggressive. Ideally it would change with draw distance, but I haven’t implemented that yet)
See lib/voxels.js if you want to glance at the code, and thanks for reading! I hope you enjoyed this quick dive into technical details. If you have any questions, feel free to contact me. Take care!