Bulk in the Future, in UE5: Nanite, HLOD, Landscapes, and more
Unreal Engine comes with an overwhelming amount of tools built in. Features such as HLOD and World Partition make our lives easier by helping us manage large worlds, Nanite challenges our existing workflows but boasts excellent scalability (despite the overhead), PCG gives us the power to accelerate our workflows and create bigger worlds and speed up iteration, and many more.
However, unfortunately, we also sometimes face a lack of documentation, critical information not being out in the public, widespread misinformation, and a general lack of understanding of how the engine works. I am not immune to these issues! There are sometimes shortfalls in the tools we are given, as well, in addition to the difficulty of determining how to achieve our goals.
While working professionally and developing Bulk in the Future, many questions arose on the best way to approach development challenges in UE5. The latter was a good opportunity to experiment with full freedom on what works best. And to make something cool!
Nanite and foliage
One of the biggest additions in UE5 has been Nanite. It has seen some poorly argued controversy but is simply misunderstood and exceedingly different from what we’re traditionally used to. You are not obligated to use it and it can easily be disabled, but for anything expected to run at high fidelity, there are outstanding ways we can take advantage of it.
Don’t use masked materials with Nanite
Seriously, avoid it at all costs unless absolutely necessary and in small quantities. Nanite hates transparent/masked geometry. VSMs hate it even more. Nanite foliage can be tricky, but so is “traditional” foliage! It’s why masked cards exist in the first place! If we adjust how we handle it, we can get some truly outstanding results.
Trees
In Bulk in the Future, trees were generated with PlantFactory such that leaves are full geometry.

It is really cool to have individual, detailed leaves. They’re not even flat planes, they bend in the centre and twist in the wind. This was simply impossible before.
However, there are some issues.
First, and this is something Epic tried to tackle already, is that Nanite tends to kill thin geometry once you get further away. It makes sense, but it destroys trees. There is a “preserve area” checkbox in a static mesh’s settings, but I found that in my case it was not enough. Yes, the tree on the right is very blurry because it’s very far and you definitely won’t see its details/leaves. But when you have a “forest” full of sticks instead of the lush trees you see up close, you have a very ugly problem.

Second, in theory, Nanite eventually creates an impostor for the mesh which is much better for performance. In my case, the mesh would get destroyed before it got to that point and even then, performance was not that great. The (highly detailed) mesh still contributes to the overhead of the Nanite VisBuffer in a significant way. Nanite may scale well with more triangles, but that doesn’t mean the cost is zero. It can absolutely still become a problem, especially if all of the triangles are tiny.
We can work around this by combining Nanite with (non-masked) billboards.

At this time, there is no way to combine custom LODs with Nanite. This is a big wishlist item of mine for the future of Nanite. I would really like to be able to add an additional mesh to the settings of my Nanite asset. This would be highly beneficial for swapping to cheaper materials, as well. Instead, I take a somewhat janky but effective approach: since my trees are placed with PCG, I also place the example impostor above at the same location.
It is also not possible to define a minimum distance for an object, only a maximum. To work around that issue, we can use Ubisoft’s trick of dividing the vertex shader output position by 0. For Unreal, this is more or less the world position offset output node (with some caveats, but that is a different topic). This requires you to mark WPO to be always evaluated, but since it doesn’t really move (depending on how you implement it) the cost is very minimal. You can either disable shadows and/or set the cache invalidation behaviour of the VSMs to static, as well. In practice, the cost of this was near-zero. Add some fog, and you get a fairly smooth transition and good appearance.
However, this is not the only way to handle it, and I think I would consider a slightly augmented version of this, especially if you’re uncomfortable with putting NaNs in shader code :). More on that in the HLOD section…

Other foliage
As for the rest of the foliage, they are Megascans assets that originally came with masked textures. It’s a bit of a tedious process, but the approach taken for Bulk in the Future was to import the mesh into Blender and subdivide it heavily. Then, you can create a vertex group for that object and, using a modifier, define it based on an imported texture. Once you’ve done that, you can select the faces in that vertex group and delete them. After a bit of additional processing, such as smoothing, you have a relatively fast pipeline for converting masked meshes to fully modelled geometry.

Nanite, landscape, and HLOD
A large open world is one of the most common things games tend to brag about. Bulk in the Future is no exception! The landscape spans 64km², with a playable area of about 20km², and additional “far landscape” which brings the total visible world (on a sunny day) to over 500km².
Nanite landscape HLOD
Some people have reported better or similar performance when not using HLODs with Nanite landscapes. Not generating HLODs for and not spatially loading/streaming landscapes can have some huge benefits in package size and complexity. But is that actually true?
It depends. Kind of.
It’s really specific to the type of game. For most games I’ve worked on and would like to work on, I would say I would lean towards no HLOD on Nanite landscapes. Why?
I ran some tests on Bulk in the Future with identical builds closer to the end of development where the only difference was the presence of landscape HLOD. The lack of HLOD is obviously not an improvement in every way, but it has some nuance and trade-offs.
Note that my tests are not 100% scientific, but it wasn’t a messy, single run. Tests were run on a laptop with an i7 7700HQ and GTX 1060 with nothing else running. Unreal Insights, when used, was connected over the network. Presented data are approximate average recordings. It is also very important to keep in mind this is a Development build, which has a massively heavier cost to the Draw/Render Thread (at least in this game). Finally, results per-game may differ, as well.
HLOD VS no HLOD
What are the impacts on the GPU?
First, obviously, memory usage was higher. The landscape here is 8km by 8km, which is the maximum landscape Unreal will allow you to create by default, and the difference was around ~100-150MB. For a huge landscape in a high-fidelity game, it’s not that bad.
Also, in most cases, the GPU time was the same. However, there was one case where looking out towards a further distance seemed to perform worse with HLODs.

Somewhat bizarrely, the base pass cost is higher, but the VisBuffer is about the same. I did not have Pix running at the time, so I ran the same test again and was not able to reproduce this result… On the second run, the visibility buffer was (just barely) higher… as far as I am concerned, if there’s any GPU cost difference between the two, it’s barely noticeable or measurable.

Looking at the other threads, we see a few other changes. Like I said, Draw/Render Thread is huge, but it’s massively reduced in Shipping builds and fairly insignificant. However, the difference between the RHI time was consistent; HLODs reduced RHI time. However, this was not particularly meaningful because there were no scenarios where it was the bottleneck in this game.
Without HLODs, the prim count was obviously much higher. RAM usage was also noticeably higher. Again, it depends on your game whether this makes a real difference for your target or minimum hardware.
The game thread was affected as well. Upon inspection, this is, unsurprisingly, mostly caused by higher costs in the landscape subsystem. However, it seems to have something to do with the grass system, which I’m not using, so it may be possible to mostly remvoe this, anyway.

Wait, it seems that most results point to the lack of HLODs being worse, don’t they? So why do I personally lean towards not using landscape HLODs?
Well, for one, we’ve seen that most of the downsides have been memory usage, which for this game are not a bottleneck. If your game is bottlenecked by memory, then you may benefit more from the decreased memory usage. It depends. The answer is always it depends.
Moreover, GPU cost is about the same, if not better. For a lot of games, this is usually the main bottleneck. This is even more true now for UE5 if you use Nanite and Lumen.
Finally, your landscape isn’t the only thing you should be loading spatially and HLODing. World partition only supports one load distance right now. Thus, we arrive to the main and biggest reason why I think Nanite landscapes work without HLODs: it’s a better tradeoff to keep your landscape loaded and instead use a lower and more aggressive load range for everything else.
So, you should definitely still use HLODs. Just not for your landscape. Also, don’t forget that unloaded landscape Actors will remove the collision, too. This may help with other Actors falling through the ground, assuming this is an issue for you. It’s just nice to have less complexity and problems to think about. The landscape HLODs would also contribute about 1.5GB to the build size of this game.
Also, landscape Actors are pretty heavy. Loading them in and out can cause more spikes than streaming other assets, especially if the alternative allows for granular, smaller tiles.

Bleh, spikes. No one likes spikes.
Tree impostors
Remember the tree impostors? You don’t necessarily have to put their minimum distance logic in the material.
One important point about the non-landscape HLOD is that my full-detail trees are not in the HLOD. The HLOD uses my impostors instead. This means that if your impostors are in the HLOD but your normal trees are not, you can just rely on the HLOD load distance to swap between in the impostors. This would require building the HLODs with the impostors included, and then disabling/removing the impostors from the instanced static mesh that is actually used in-game. How you handle that is up to you.

Conclusion
Is this a comprehensive guide? No, this is a collection of my findings, which might have mistakes.
Is Bulk in the Future perfect? Absolutely not. Not even remotely close. There are a lot of things that could be better. It has fulfilled its purpose, though; it looks pretty good and performs decently well, achieving a solid 50-60fps on Steam Deck on Medium graphics settings and FSR3 Quality. Could it look and run better? Yes, but it answered some crucial questions that will help make my future projects better!
Give it a try
If you’ve read this far, why not take a break? Download Bulk in the Future for free on itch.io and see the result for yourself.