After a data structure is decided upon for a Voxel system, you run into the problems of rendering. As mentioned in a previous blog post exploring a screw up of mine, the crux of my solution is this:
“In brief, the final render system works similar to how other such systems do. A chunk is generated by building meshes by vertices and then once the desired size has been reached, a new chunk is started, repeat. Any Voxels that don’t touch an empty space aren’t rendered, essentially culling a lot of the work that the extra meshes would cause the graphics card. Chunking in itself also lowers the amount of draw calls made, resulting in (generally) better performance all around.”
Generating mesh is only one portion of the overall challenge. The second is that of effectively optimising what is displayed so that the system runs as fast as possible. Why bother displaying chunks that can’t be seen by the user? The answer to that is simple: Culling.
The less meshes the graphics card has to draw, the less overall drawcalls made, the less vertices displayed and the faster everything runs. Unity’s rendering engine has the wonderful function of camera frustum culling – if it’s not visible by the camera’s field of view, it’s unloaded from the graphics pipeline. In most situations, you can combine this culling with Unity’s own baked occlusion mapping to save drawcalls made creating geometry the player cannot see at that position (usually because said geometry is behind another). Great! But…
Unity is Not Enough!
The issue is that we’re dealing with procedural geometry that is generated upon the loading of the program. Unity’s occlusion system is solid, but as hinted before, is baked: It relies on the meshes being there already so that an occlusion map can be built and stored in the editor. When things are dynamically generated, this is impossible with the provided tools. So a workaround must be constructed.
Chunks to the Rescue
By rendering in chunks, you not only manage to avoid generating too large a mesh, but you also allow for culling of entire areas of the world that wouldn’t be visible to the camera. The issue is how to cull effectively and with minimal overhead.
The easiest solution by far is to draw lines between the camera and the chunks, and removing any chunks that cannot see the camera, or vica-versa. After various attempts, the latter became the smarter option, hence Camera.ViewportPointToRay(), a wonderful function that allows you to draw a virtual line from a given point in the viewport (best described as the camera’s field of view) out from the camera.
The concept is that any chunk a camera ray hits will be rendered, all others will be hidden. Rays won’t travel through chunks due to the reliance on physics colliders, and chunk mesh shape is unimportant assuming the automatically generated collision mesh doesn’t fill any wanted holes. In a perfect world, the entire viewport would fire out rays every frame and cull anything invisible.
This is not a perfect world. The overhead of tracing rays (many thousands per frame), and handling collisions for such a scenario is immense. It’s untenable in any situation, let alone a real-time application aiming for 90 frames a second!
in order to save this overhead, I took the approach of adding a “gaze” timer to each chunk, and scanning across the viewpoint in vertical scanlines. Whenever a chunk is hit by a raycast line, it is set as visible and starts to count down. If the chunk is not hit by another raycast within the time limit (currently 5 seconds in the prototype), it disappears. If it is, the timer resets. Scan from both sides in both directions and you end up with a laser butterfly of cheap culling:
By handling only a couple hundred points rather than several thousand, the overhead is vastly reduced. Even with two cameras, as is the standard for VR, we’re talking huge gains of performance:
Obviously the method will require further tweaking – cameras hooked to a VR headset aren’t exactly a stable scenario versus a still camera being dragged around. But it’s a start, and one that can be built upon.
Next step: Nailing down the simulation!