LOD system(s) in C++ for Godot

Arman Elgudzhyan |  28 August 2020  |
(2020-12-27)
A newer version of this system has been released and some of this info may be out of date. Most of it still applies, though. See here for the newer post.

 

Currently, for Godot 3.x releases, LOD functionality is not implemented. I’ve seen some solutions online, but they were written in GDScript and I figure it may not be sustainable for a large number of objects. Luckily, Godot allows us to write C++ plugins through GDNative. This is a quick run-through of my LOD system, available on my godot-extras repo here, as well as some side tips on C++ in Godot. This system also goes further than just regular object switching and includes more specific distance-based optimizations, such as turning off shadows for far away lights.

Basics

For all of the scripts, we define the distance at which we want to adjust our objects. For most cases, setting the distance to -1 will simply cause that case to be ignored. In addition, as a very basic performance improvement, there is a tick speed (in seconds) that limits how often we check the distance or change object properties.

We also have “multipliers” we can use (if you compile the engine with the patch) to affect the various distances globally. These are not updated every frame after a change (unless you specify so). You can disable whether the multiplier affects an object individually in its settings.

I tried to add the multipliers with a plugin, but the C++ code would not be able to load them at game launch. I suspect the plugin may be loaded after the GDNative code begins running. (They work with the addon now. No patch needed.)

All nodes

You can attach the basic lod.gdns file to any object to enable switching, hiding, and unloading (queue_free) of its children.

The distance variables represent the lengths after which you’ll switch to LOD1/LOD2/LOD3, hide all objects, or queue_free the whole thing.

“Disable processing” will control whether _process and _physics_process will run on the disabled nodes or not.

The LOD paths can actually be left empty, as you can see. You can assign them via the editor, but the code will automatically search for child objects containing LOD0/LOD1/LOD2/LOD3 in the name when the game loads. No need to click around hundreds of times!

A note on accessing nodes in C++ in Godot:

If you want to set objects at runtime, you can add a NodePath in the header file

NodePath lod0path;
NodePath lod1path;
NodePath lod2path;

Spatial* lod0 = NULL;
Spatial* lod1 = NULL;
Spatial* lod2 = NULL;

Camera* camera;

then register the variable under _register_methods() in the .cpp

register_property<LOD, NodePath>("lod0path", &LOD::lod0path, NodePath());

However, for objects you’ll be accessing and modifying, you should be defining a pointer to the correct node type in the header and assigning/accessing them with something like this:

childNodes = get_children();

...

Spatial *child = Object::cast_to<Spatial>(childNodes[i]);
if (child->get_name().find("LOD0") >= 0) {
    lod0 = child;
    break;
}

...

Lights

But wait, there’s more!… Why don’t we try to optimize lights as well?

Past shadow dist and hide dist, the shadows and lights will be completely faded out, respectively. We don’t want to constantly fade back and forth at the distance edge, so there is a fade range that will decide how much to fade. Once either hits 0, they will be automatically disabled.

A note on accessing Godot data in C++ on game load:

Remember to use _ready() and not _init() for working with Godot-specific data. Otherwise, you will get a silent crash with no explanation or error messages.

void LightLOD::_ready() {
    // Find camera and save original light and shadow colours
    camera = get_viewport()->get_camera();

    lightBaseEnergy = get_param(PARAM_ENERGY);
    lightTargetEnergy = lightBaseEnergy;
    shadowBaseColor = get_shadow_color();
    shadowTargetColor = shadowBaseColor;
}

MultiMeshInstance

The number of visible instances will adjust between min and max distance based on min count and max count (leave max count -1 to initialize it to instance/total count at runtime).

If you don’t want a linear transition, there is a fade exponent available. It controls the curve at which objects are shown or hidden.

GIProbes

Now this is an interesting one…

For it to make sense, though, you’ll probably want to (re)compile the engine with my GIProbe blending patch. By default, you can only blend 2 GIProbes on one object, and there is no compensation for the environment/ambient light when you lower the energy on a GIProbe (so it looks like “interior” is checked). Using this patch, 4 GIProbes can blend (at a performance cost) and the loss of energy from lowering the intensity of the GIProbe will be compensated using the environment energy (if “interior” is not enabled).

Before and after:

The idea is to lay out your GIProbes and game world into offset chunks like this (blue squares are chunks of the map, green squares are GIProbes):

Ideally, you do some careful placing of GIProbes and try to go for getting GI coverage for a larger area without tanking performance. Pay attention to objects placed between the fade points and the fade speed/range you set.

Testing

Finally, here is a video demonstrating most of the systems used at once and some basic performance numbers.


The graph from the end of the video:

Not a bad result for a quickly done test!

Next steps/considerations

Multithreading would be an obvious improvement for this system. Haven’t had many solid ideas on how to implement it yet.


Maybe change the system to use a singleton controller (which can store the camera info so we don’t need to retrieve it hundreds of times for each object) and use smaller scripts without using _process(). The objects will also be added to a group so the controller can send out the camera info and call for updates to all LOD objects.

Pros:

Complications:


Most engines base LOD on size relative to screen/pixels, not just distance. This could probably be done using some AABB and camera FOV math. Of course, a change in FOV will then require a change to the LOD distances.