Rendering at scale: Efficient strategies for massive object counts


Investigate what’s causing rendering issues and then optimize

Rendering is a collaboration between your CPU and GPU.

“The CPU is the coach, and the GPU is the athlete. The CPU calls the play and the GPU carries them out,” says Liam Dudas, optimization engineer on Backyard Baseball.

Sometimes the work the GPU has to do is more time-consuming than other tasks. Complex meshes, shaders, and lighting can cause slowdowns, and the best way to improve performance is to reduce their complexity by asking the artist to optimize these assets.

Other times, it’s not what the CPU asks the GPU to do, but how it asks it.

Is the CPU executing its instructions optimally? Is it taking a long time to figure out what to draw, making the CPU twirl its thumbs instead of getting the rest of the game to run? Is the CPU providing information redundantly? A good coach can inspire an unexpected comeback from his team, but when it comes to optimization, we can’t just get these systems to “try harder.” It’s all about understanding the information they’re dealing with and how they communicate it.

This comes down to optimizing the draw calls the CPU passes to the GPU. The more work we can pack into one call, the better, especially when there are tens of thousands of objects to render.

There are plenty of tricks to achieve this – but a word of caution before you do.

Don’t just optimize willy-nilly, for nothing more than the thrill of clever code. Always benchmark first. Find where your project is suffering the most. The first question you should always ask: Is your bottleneck in the CPU or the GPU?

Some of the solutions in this post increase CPU speed, others GPU speed. You don’t want to get carried away optimizing one processor when the other one is actually the source of your frame spikes. Once you decide whether your game is CPU- or GPU-bound, investigate which procedure is taking the longest, then optimize that. Unity’s profiling best practices guide is a great place to start, which includes a handy flowchart that provides guidance on the often mysterious process of profiling.

“I have to do a lot of profiling,” says Liam. “I can tell when the CPU is doing a lot, and the GPU isn’t, and vice versa. Really, our worst bottleneck has been both of them at different times.”

We included a slew of our favorite resources at the end of this post to help you on your own profiling journey.

And with that disclaimer out of the way, let’s explore optimizations.

Simplifying and culling

When rendering, we need to determine which meshes to render and where.

The more meshes we have and the more draw calls we need, the more the CPU has to keep figuring out what to do while the GPU waits for its next job.

We could speed up the process by rendering fewer meshes. For example, you could swap complex meshes with imposters, billboards, or low-poly versions; or you could break up a sprawling world into smaller levels with less to render at once. These are tried-and-true solutions you should use when you can, but they limit the level of detail in your environments.

One optimization that avoids this, and is simple to implement, is occlusion culling, where the camera checks what’s within its field of view and avoids rendering what the player can’t see. This saves time at the cost of increased memory usage, but it’s often worth it in environments with many stationary meshes, like detailed rooms in an office building. This optimization is an industry standard, but it’s often not enough on its own.

Static batching

Another way we can limit the number of meshes we render without sacrificing variety is by combining them into a single mesh.

You can do this yourself manually. Or, so long as the meshes share the same material, Unity can do this for you in a process called batching. Set a game object to static, and Unity will automatically batch all of the meshes with the same material together to cut down the draw calls. Use this option on anything that stays static, like trees and walls.

This drastically reduces RAM usage and CPU overhead, allowing the GPU to render more content at once. But keep in mind, more GPU memory will be used to store the combined meshes.

Static batching is often our #1 key optimization process, particularly for Backyard Baseball. Using URP’s batching system is simple, so the most challenging part is figuring out how to use as few materials as possible across as many models as possible. That balancing act is why we have some of the most creative cats on our optimization and art teams.

GPU instancing

This is a good time to start distinguishing between a thing… versus an instance of that thing.

Let’s say you have a mesh. A mesh is an asset. And its material is an asset. They are single files that exist in your file structure. For every object with a 3D appearance in your game, a mesh describes its shape, and then a material describes how its surface looks.

But because Unity simplifies its details, some developers don’t realize that when their game is running, they actually have multiple copies of these things instantiated into their game world. When your player explores a forest, they’re moving through many copies of that one tree stored as an asset in their game’s files.

This is why we used GPU instancing for the foliage in Backyard Baseball. Instead of rendering each copy of the tree with unique calls to the GPU, the CPU instead asks the GPU to render all identical models at once. If you’re not careful, this could result in every tree in the forest looking identical. But, with some effective parameters in the material’s shader, for example, a value to customize the color of the leaves, you can pass to the GPU one model and material, along with the unique colors for each tree, rather than asking for each tree individually.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *