Porting the Godot LOD plugin from GDScript to C sharp

Quick note: for whatever reason, "#" won't show up in the title. Might be a bug, edited the title accordingly.

So, today I had to be on call for work, in case someone else's (or my own) code decided to break and I'd have to swoop in and save the day. That essentially means that I need to stay reasonably close to a computer most of the time, even though I don't have to work per se. Because of this, I decided to do something fun and productive at the same time, for a change!

You see, there's this lovely open source game engine, Godot, which has also been used for visualizations, tools and some other software. It is currently in the process of being migrated to its next release, Godot 4, however in the mean time many are still using the more stable version Godot 3. The problem with it, however, is that its 3D performance leaves something to be desired, due to it not having been the main priority of the engine back in the day.

But fear not, there are many approaches, like frustum and occlusion culling, which can help to varying degrees. One of these approaches, level of detail has a lot of potential to improve the performance of your project across a variety of devices, by swapping out models of a higher graphical fidelity for those of a lower one, as they get further away from the camera:

level-of-detail-example

This typically works out really nicely, because when the objects are further away, they also take up fewer pixels on the screen and therefore you don't need to show all of the triangles that make up the full model and instead can replace it for a simplified one, which in turn stresses your GPU less.

Curiously, Godot already had a plugin for this, however it was written in their own proprietary language, GDScript. Now, make no mistake, the language itself is rather decent, is tightly integrated with the functionality of the editor and is generally performant enough, but personally, I wanted more. I wanted to see, whether I could port the plugin over to C#, to see how easy it is to use it with Godot in the current version, as well as whether there'd be any noticeable performance improvements.

And, after a bit of work, that's exactly what I got:

still-runs-in-editor-sometimes

How did I do it?

Seeing as the original project was MIT licensed, I cloned the repository, as well as the attached demo scene, which I intended to use for regression testing. I wanted to be able to immediately see, whether both of the implementations are still identical after my changes, so that also meant having two projects, one (the original) in GDScript and the other (my code) in C#:

two-separate-projects

Essentially, I reworked the repo structure a little bit, to have both of the demo projects adjacent to one another, in case I'd need to make any changes to the project structure, which actually came in handy a little bit later:

new-repo-structure

Due to how C# works, however, I ended up with a slightly odd workflow, however. I developed the code in the demo project, where my C# project could easily integrate with the Godot libraries and I could check whether this external plugin can actually work with a real world project without anything breaking. That did, however, mean that I'd have to copy back the changed demo project addon code into the main directory, so for now most of my commits contain duplicated code - both in the demo project and the addon folder:

workflow

Admittedly, however, I didn't really find the need to setup a symbolic link or another mechanism for ensuring this and a shell script turned out to be completely sufficient. It's just a small detail that I found interesting, because otherwise I'd have to somehow include the plugin in the demo project, possibly through local NuGet packages, but that seemed like too much work for now.

The end result, however, was pretty good, I could run both implementations side by side:

side-by-side

This is extremely good for testing, much like in regular software development you might have environments with and without certain new changes or features, for regression testing. Or how you might want to run A/B tests to measure the impact of some changes on a subset of your users, before rolling the feature out to everyone. It's good to see that developing tools can also have something a little bit like it.

The good

Actually, the whole idea of porting the plugin over proved to be a pretty nice process! Now, we can have a quick look at the GDScript code from the original implementation, which can be edited right there in the editor. Visually, it's actually rather similar to Python and is pretty easy to learn, even if it's a little bit slower at runtime:

code-gdscript

Now, I still think that GDScript is a good choice, especially for rapidly prototyping some functionality that you need, much like other higher abstraction approaches to programming on other engines have also gained a following. For example, in Unreal Engine, you have the Blueprints system, which is a form of visual programming. In this regard, GDScript is both easy to use and a real programming language (it scales better than most forms of visual programming; outside of something like creating materials with a node based system).

That said, I already use some of JetBrains IDEs and therefore being able to use Rider with C#, a world class IDE for a world class language, feels like a no brainer to me. You see, the quality of writing, refactoring, testing and analyzing code is almost always going to be better in a tool that's purpose built, rather than an editor that's tacked on to another product:

code-csharp

Furthermore, C# has an excellent type system, which can also help me write code that's both easy to refactor and easy to have confidence in its correctness. I can also take advantage of things like enums and various design patterns (yes, even including the Singleton pattern, if I so choose), as well as any other number of language features or approaches that I might have been used to:

code-csharp-constants

And yes, the fact that I write lots of Java in my day job is absolutely showing here, so it also offers me formatting and naming convention advice, so that the code I write ends up being more idiomatic in regards to what's accepted as conventions in C#:

code-csharp-suggestions

But that's not all: refactoring is also way more easy, doing things like extracting variables, methods and interfaces is as natural as in any other codebase and you can even take advantage of any number of additional libraries, all available to you through NuGet.

GDScript is pretty cool, but interfacing with SQLite databases, remote REST/gRPC services or transforming data between various formats will almost always be easier in a more traditional and more widely used language.

Show me the code

But the first question you might actually have, is whether the code is all that different after my porting. Well, I think that you should just judge for yourself!

Here's the GDScript code for one of 6 classes (tabs converted to spaces for display here):

# Copyright © 2020 Hugo Locurcio and contributors - MIT License
# See `LICENSE.md` included in the source distribution for details.
extends Spatial
class_name LODSpatial, "lod_spatial.svg"

export var enable_lod := true

export(float, 0.0, 1000.0, 0.1) var lod_0_max_distance := 10
export(float, 0.0, 1000.0, 0.1) var lod_1_max_distance := 25
export(float, 0.0, 1000.0, 0.1) var lod_2_max_distance := 100

var refresh_rate := 0.25
var lod_bias := 0.0
var timer := 0.0

func _ready() -> void:
    if ProjectSettings.has_setting("lod/spatial_bias"):
        lod_bias = ProjectSettings.get_setting("lod/spatial_bias")
    if ProjectSettings.has_setting("lod/refresh_rate"):
        refresh_rate = ProjectSettings.get_setting("lod/refresh_rate")

    randomize()
    timer += rand_range(0, refresh_rate)

func _physics_process(delta: float) -> void:
    if not enable_lod:
        return

    var camera := get_viewport().get_camera()
    if camera == null:
        return

    if timer <= refresh_rate:
        timer += delta
        return

    timer = 0.0

    var distance := camera.global_transform.origin.distance_to(global_transform.origin) + lod_bias
    var lod: int
    if distance < lod_0_max_distance:
        lod = 0
    elif distance < lod_1_max_distance:
        lod = 1
    elif distance < lod_2_max_distance:
        lod = 2
    else:
        lod = 3

    for node in get_children():
        if node.has_method("set_visible"):
            if "-lod0" in node.name:
                node.visible = lod == 0
            if "-lod1" in node.name:
                node.visible = lod == 1
            if "-lod2" in node.name:
                node.visible = lod == 2

Now, the original actually had comments, but I've removed those here just so it's a bit shorter. As you can see, it's very much like Python, with additional bits of magic, like export or class_name sprinkled in here and there. Pretty easy to use, but once again, it doesn't really give you as much confidence in your code as a static type system might.

For comparison's sake, here's the C# implementation:

using Godot;

[Tool]
public class LODSpatial : Spatial
{
    [Export]
    public bool EnableLod = true;

    [Export(PropertyHint.Range, "0.0, 1000.0, 1.0")]
    public float Lod0MaxDistance = LODDefaults.DefaultSpatialLod0MaxDistance;

    [Export(PropertyHint.Range, "0.0, 1000.0, 1.0")]
    public float Lod1MaxDistance = LODDefaults.DefaultSpatialLod1MaxDistance;

    [Export(PropertyHint.Range, "0.0, 1000.0, 1.0")]
    public float Lod2MaxDistance = LODDefaults.DefaultSpatialLod2MaxDistance;

    private float _refreshRate = LODDefaults.DefaultSpatialRefreshRate;
    private float _lodBias = 0.0f;
    private float _timer = 0.0f;
    private int _lod = 0;

    private float _distance;

    public override void _Ready()
    {
        if (ProjectSettings.HasSetting(LODDefaults.SettingLodSpatialBias))
        {
            _lodBias = (float)ProjectSettings.GetSetting(LODDefaults.SettingLodSpatialBias);
        }

        if (ProjectSettings.HasSetting(LODDefaults.SettingLodRefreshRate))
        {
            _refreshRate = (float)ProjectSettings.GetSetting(LODDefaults.SettingLodRefreshRate);
        }

        GD.Randomize();
        _timer += (float)GD.RandRange(0, _refreshRate);
    }

    public override void _PhysicsProcess(float delta)
    {
        if (!EnableLod)
        {
            return;
        }

        if (_timer <= _refreshRate)
        {
            _timer += delta;
            return;
        }

        Camera camera = GetViewport().GetCamera();
        if (camera == null)
        {
            return;
        }

        _timer = 0.0f;

        _distance = camera.GlobalTransform.origin.DistanceTo(GlobalTransform.origin) + _lodBias;
        if (_distance < Lod0MaxDistance)
        {
            _lod = 0;
        } else if (_distance < Lod1MaxDistance)
        {
            _lod = 1;
        } else if (_distance < Lod2MaxDistance)
        {
            _lod = 2;
        }
        else
        {
            _lod = 3;
        }

        foreach (Spatial node in GetChildren())
        {
            if (node.HasMethod(LODDefaults.SpatialSetVisible))
            {
                if (node.Name.Contains(LODDefaults.SpatialLod0))
                {
                    node.Visible = _lod == 0;
                }
                if (node.Name.Contains(LODDefaults.SpatialLod1))
                {
                    node.Visible = _lod == 1;
                }
                if (node.Name.Contains(LODDefaults.SpatialLod2))
                {
                    node.Visible = _lod == 2;
                }
            }
        }
    }
}

Admittedly, it's almost twice as long, though personally I mostly blame the fact that Rider expands { } across multiple lines, which apparently is done to increase readability. On the bright side, the export keyword carried over nicely into [Export], so integrating with the engine is actually pretty easy, given that it doesn't depart away too far from how C# works.

Also, I could have used var for variables and just let the runtime figure everything out, but here I also went through the trouble of specifying types (which actually was just picking which variable format I wanted from a dropdown list in Rider), so it's immediately apparent what we're looking at.

Of course, some peculiar bits of Godot's dynamic approach to programming are shining through, like:

if (node.HasMethod(...))
{
    ...
}

In engines like Unity, you're sometimes working with component based systems, where you can get components that belong to an object by their type, such as getting the RigidBody that's attached to a GameObject, giving it physics behaviour. Yet here, Godot works with a Node based system, where each node can have at most one node that it inherits functionality from (as well as only one script), so a lot of functionality depends on figuring out which nodes can do what at runtime, depending on their type, as well as nested object hierarchies.

Of course, I wouldn't call it an outright bad thing, just a different design - it does compose rather well, though, because you can have scenes composed of nodes inside of other scenes, as opposed to how awkward using prefabs in engines like Unity sometimes feels.

The bad

That said, there are some things that were bad, or outright broken, to be honest. Perhaps owing to the fact that C# isn't quite a first class citizen in Godot and lots of attention is instead focused on GDScript, which is also kind of understandable, because the latter is easier to get into for most people without formal CS backgrounds.

The first instance of problems that I ran into was warnings about missing resources:

no-resource-error

Which is fair, if I actually had any way to fix those, because currently I actually see that I have the resource available:

no-resources-error-though-we-have-them

Personally, I think that this is most likely a problem of dangling references somewhere in the scene, however I cannot locate what's wrong exactly, nor will the logs tell me how to solve this problem. That's probably on the roadmap, since it's not a good look, especially because the rest of the node types do not have that odd problem.

Where things really took a sharp turn for the worse, was the fact that I could not compile my solution:

fails-to-compile

As it turned out, when you try to export your game, the [Tool] scripts won't actually be available, because those are meant to primarily run in the editor. Which is fine, except that the compiler still tries to include them in the production build, which hits a roadblock, complaining about the necessary functionality not being available.

I mean, that might be fine if the tool script were to be excluded, but what Godot does is instead exclude all of my C# code from the project, at least based on some testing.

So basically:

  • I get a warning about a failed compilation
  • The project is still exported as a runnable .exe (or another format, for other platforms)
  • The project runs, just without any code

I mean, hey, we've all heard about low-code or no-code tools, but what about no-code games? That's a new one!

I did manage to fix it in the end, but in a rather odd way - essentially I rewrote the tool script which initializes the plugin and adds the new Node types to the editor in GDScript, instead of C#. Because for whatever reason that GDScript bit of code just doesn't get loaded outside of when it's running in an editor context, as opposed to preventing the whole project from being compiled.

Also, that whole [Tool] attribute (the equivalent of which are annotations in Java) seems a bit oddly implemented. Because currently, if I do not add it to my classes, I'll get warnings from Godot, whereas if I do add the attribute to my classes, the code will do exactly what the attribute suggests, and run in the editor:

still-runs-in-editor-sometimes

This will lead to the editor having LOD work inside of it, which seems kind of brilliant (I just made the editor more performant, instead of just the game) but also might be a bit annoying, given the fact that scenes might be saved with some Nodes having lower LOD models rendered. I guess it's a matter of perspective, really.

Either way, after a little while, I carried over all of the functionality that the plugin provided:

  • LOD for game objects (LODSpatial)
  • LOD for particles (LODParticles and LODCPUParticles)
  • LOD for lights (LODSpotLight and LODOmniLight)

So then what?

The fast and furious

Well, I decided to do some refactoring and improve performance if at all possible, as well as do some basic benchmarking.

Actually, doing that proved to be pretty easy with C#, a Dictionary here for easier data access, a cache of the nodes to iterate through right next to it so we don't need to traverse a tree structure in depth every time we want to find an object:

refactoring-for-performance

Not only that, but I increased the size of the test demo scene a little bit, with the goal of seeing how the plugin implementations scale when dealing with more objects, because the distance from the camera and which LOD model should be shown (or how lights/shadows should be processed, what the particle visibility should be) is calculated per each object:

performance-test-scene

In the end, the results were rather interesting. Actually, here's a quick video comparing the various implementations at runtime (although it's recorded at 30fps), as well as showing the frame rate statistics across the previous 30 seconds (min, max and average framerates):

Now, Kdenlive might have shuffled around the captions a little bit, but a few things become apparent. The results might surprise you!

Summary

So, what's the end result of my little project?

Well, the performance benchmarking showed a few interesting things:

  • neither the GDScript, nor C# implementation stressed the CPU too much, it seems like game engines are pretty well optimized as long as you don't write bad code
  • both of the LOD implementations hanged around over 100 FPS, though there was surprisingly little difference between them (with GDScript having a few fewer FPS)
  • running without LOD was pretty horrible, though, with the performance dipping down to around 30-50 FPS

So, in summary, either LOD implementation was able to give us twice the frames per second, so using any sort of plugin for this is definitely a no brainer, when you need to render lots of objects.

Of course, it's not quite as simple - since if you're using Godot 3 instead of Godot 4, you'll still need to manually generate the LOD models in a program of your choice, such as using the decimate modifier in Blender, for something really simple. In addition, you'd also need to fine tune the settings, so that you don't have larger objects like buildings or mountains fading out of view, because the plugin currently doesn't differentiate between large and small Spatial objects.

I'm not releasing the source code for my C# implementation just yet, because it seems like the GDScript one is more than adequate for most people. That said, I'm still thinking of using the C# one in any of my own future projects, like a tool for data visualization, or maybe a simple simulation game that I've been wanting to develop for a while now!

Either way, LOD is a great mechanism to use in your games and tools, when dealing with 3D graphics!

By the way, want an affordable VPN or VPS hosting in Europe?
Personally, I use Time4VPS for almost all of my hosting nowadays, including this very site and my homepage!
(affiliate link so I get discounts from signups; I sometimes recommend it to other people, so also put a link here)
Maybe you want to donate some money to keep this blog going?
If you'd like to support me, you can send me a donation through PayPal. There won't be paywalls for the content I make, but my schedule isn't predictable enough for Patreon either. If you like my blog, feel free to throw enough money for coffee my way!