During the past days, this great article by Sam Pruden has been making the rounds around the gamedev community. While the article provides an in-depth analysis, its a bit easy to miss the point and exert the wrong conclusions from it. As such, and in many cases, users unfamiliar with Godot internals have used it points such as following:
- Godot C# support is inefficient
- Godot API and binding system is designed around GDScript
- Godot is not production ready
In this brief article, I will shed a bit more light about how the Godot binding system works and some detail on the Godot architecture. This should hopefully help understand many of the technical decisions behind it.
Compared to other game engines, Godot is designed with a relatively high level data model in mind. At the heart, it uses several datatypes across the whole engine. These datatypes are:
- Nil: To indicate an empty value.
- Bool, Int64 and Float64: For scalar math.
- String: For String and Unicode handling.
- Vector2, Vector2i, Rect2, Rect2i, Transform2D: For 2D Vector math.
- Vector3, Vector4, Quaternion, AABB, Plane, Projection, Basis, Transform3D: For 3D Vector math.
- Color: For color space math.
- StringName: For fast processing of Unique IDs (internally a unique pointer).
- NodePath: For referencing paths between nodes in the Scene Tree.
- RID: Resource ID for referencing a resource inside a server.
- Object: An instance of a class.
- Callable: A generic function pointer.
- Signal: A signal (see Godot docs).
- Dictionary: A generic dictionary (can contain any of these datatypes as either key or value).
- Array: A generic array (can contain any of these datatypes).
- PackedByteArray, PackedInt32Array, PackedInt64Array, PackedFloatArray, PackedDoubleArray: Scalar packed arrays.
- PackedVector2Array, PackedVector3Array, PackedColorarray: Vector packed arrays.
- PackedStringArray: Packed string array.
Does this mean that anything you do in Godot has to use these datatypes? Absolutely not. These datatypes have several roles in Godot:
- Storage: Any of these datatypes can be saved to disk and loaded back very efficiently.
- Transfer: These datatypes can be very efficiently marshalled and compressed for transfer over a network.
- Introspection: Objects in Godot can only expose their properties as any of those datatypes.
- Editing: When editing any object in Godot, it is done via any of these datatypes (of course, different editors can exist for the same datatype, depending on the context).
- Languge API: Godot exposes its API to all languages it binds via those datatypes.
Of course, if you are absolutely unfamliar to Godot, the first questions that come to mind are:
- How do you expose more complex datatypes?
- What about other datatypes such as int16?
In general, you can expose more complex datatypes via Object API, so this is not much of an issue. Additionally, modern processors all have at minimum 64 bit buses, so exposing anything other than 64 bit scalar types makes no sense.
If you are unfamliar to Godot, I can totally understand the disbelief. But in truth, it works fine and it makes everything far simpler at the time of developing the engine. This data model is one of the main reasons why Godot is such a tiny, efficient and yet feature packed engine compared to the large mainstream mamooths. As you get more familiar with the source code, you will start to see why.
Now that we have our data model, Godot imposes a strict requirement that almost any function exposed to the engine API must be done via those datatypes. Any function parameters, return types or properties exposed must be via them too.
This makes the job of the binder much simpler. As such, Godot has what we call an universal binder. How does this binder work, then?
Godot registers any C++ function to the binder like this:
Vector3 MyClass::my_function(const Vector3& p_argname) {
//..//
}
// Then, on a special function, Godot does:
// Describe the method as having a name and the name of the argument, the pass the method pointer
ClassDB::bind_method(D_METHOD("my_function","my_argname"), &MyClass::my_function);
Internally, my_function and my_argument are converted to a StringName (described above), so from now onwards they are treated just as a unique pointer by the binding API. In fact, when compiling on release, the argument name is ignored by the template and no code is generated, since it serves no purpose.
So, what does ClassDB::bind_method
do? If you want to dive into the depths of insanity and try to understand the
incredibly complex and optimized C++17 variadic templates black magic, feel free to go ahead.
But In short, it creates a static function like this, which Godot calls "ptrcall" form.:
// Not really done like this, but simplifying as much as possible so you get an idea:
static void my_function_ptrcall(void *instance, void **arguments, void *ret_value) {
MyClass *c = (MyClass*)instance;
Vector3 *ret = (Vector3*)ret_value;
*ret = c->my_method( *(Vector3*)arguments[0] );
}
This wrapper is basically as efficient as it can be. In fact, for critical functions, inline is forced into the class method, resulting in a C function pointer to the actual function code.
Then Language API works by allowing the request of any engine function in "ptrcall" format. To call this format, the language must:
- Allocate a bit of stack (basically just adjusting the stack pointer of the CPU)
- set a pointer to the arguments (which already exist in native form in this language 1:1, be it GodotCPP, C#, Rust, etc).
- call.
And that's it. This is an incredibly efficient generic glue API that you can use to expose any language to Godot efficiently.
So, as you can imagine, the C# API in Godot basically uses a C function pointer via unsafe API to call after assigning pointers to native C# types. It is very, very efficient.
I want to insist that the article written by Sam Pruden is fantastic, but if you are not familiar with how Godot is intended to work under the hood it can be very misleading. I will proceed to explain a bit more in detail what is easy to misunderstand.
The use case shown in the article, the ray_cast function, is a pathological one in the Godot API. Cases like this are most likely less 0.01% of the API exposed by Godot. It looks like the author found this by coincidence when trying to profile raycasting, but it is not representative of the rest of the bindings.
The problem is that, at the C++ level, this function takes a struct pointer for performance. But at the language binding API this is difficult to expose properly. This is very old code (dating to the opensourcing of Godot) and a Dictionary was hacked-in to use temporarily until something better is found. Of course, other stuff was more prioritary and very few games need thousands of raycasts, so pretty much nobody complained. Still, there is a recently open proposal to discuss more efficient binding of these types of functions.
Additionally, to add to how unfortunate this choice of function is, the Godot language binding system does support struct pointers like this. GodotCPP and Rust bindings can use pointers to structs without any issue. The problem is that C# support in Godot predates the extension system and it was not converted to it yet. Eventually, C# will be moved to the universal extension system and this will allow the unifying of the default and .net editors, it is just not the case yet, but its top in the list of priorities.
Although this time, due to a limitation of C#. If you bind C++ to C#, you need to create a C# version of a C++ instance as an adapter. This is not an unique problem to Godot, any other engine or application doing this will require the same.
Why is it troublesome? because C# has a garbage collector and C++ does not. This forces the C++ instance to keep a link to the C# instance to avoid it from being collected.
Due to this, the C# binder must do extra work when calling Godot functions that take class instances. You can see this code in Sam's article:
public static GodotObject UnmanagedGetManaged(IntPtr unmanaged)
{
if (unmanaged == IntPtr.Zero) return null;
IntPtr intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_script_instance_managed(unmanaged, out var r_has_cs_script_instance);
if (intPtr != IntPtr.Zero) return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
if (r_has_cs_script_instance.ToBool()) return null;
intPtr = NativeFuncs.godotsharp_internal_unmanaged_get_instance_binding_managed(unmanaged);
object obj = ((intPtr != IntPtr.Zero) ? GCHandle.FromIntPtr(intPtr).Target : null);
if (obj != null) return (GodotObject)obj;
intPtr = NativeFuncs.godotsharp_internal_unmanaged_instance_binding_create_managed(unmanaged, intPtr);
if (!(intPtr != IntPtr.Zero)) return null;
return (GodotObject)GCHandle.FromIntPtr(intPtr).Target;
}
While very efficient, it's still not ideal for hot paths so the Godot API exposed is considerate and does not expose anything critical this way. The workaround used, however, is quite complex and hits this path due to not using the actual function intended for it.
I firmly believe the author did not cherry pick this API on purpose. In fact, he himself writes that he checked other places of API usages and did not find anything with this level of pathology either.
To clarify further, he mentions:
Let’s also remember that Dictionary is only part of the problem. If we look a little wider for things returning
Godot.Collections.Array<T> (remember: heap allocated, contents as Variant) we find lots from physics,
mesh & geometry manipulation, navigation, tilemaps, rendering, and more.
From my side and contributors side, none of those usages are hot paths or pathological. Remember that, as I mentioned above, Godot uses the Godot types mainly for serialization and API communication. While it is true that they do heap allocation, this only happens once when the data is created.
I think what may have confused Sam and a few others in this area (which is normal if you are not familiar with the Godot codebase) is that Godot containers don't work like STL containers. Because they are used mainly to pass data around, they are allocated once and then kept via reference counting.
This means, the function that reads your mesh data from disk is the only one doing the allocation, then this pointer gets passed through many layers via reference counting until arrives Vulkan and is uploaded to the GPU. Zero copies happen along the way.
Likewise, when these containers are exposed to C# via the Godot collections, they are also reference counted internally. If you create one of those arrays to pass the the Godot API, the allocation only happens once. Then no further copies happen and the data arrives intact to the consumer.
Of course, intenally, Godot uses far more optimized containers that are not directly exposed to the binder API.
The article concludes like this:
Godot has made a philosophical decision to be slow. The only practical way to interact with the engine is via this binding layer, and its core design prevents it from ever being fast. No amount of optimising the implementation of Dictionary or speeding up the physics engine is going to get around the fact we’re passing large heap allocated values around when we should be dealing with tiny structs. While C# and GDScript APIs remain synchronised, this will always hold the engine back.
As you have read in the above points, the binding layer is absolutely not slow. What can be slow is an extremely limited amount of use cases that can be pathological. For those cases, a dedicated solution is found. This is a general philosophy behind Godot development that helps keep the codebase small, tidy, maintainable and easy to understand.
In other words, this principle:
The current binder serves its purpose and works well and efficiently for over 99.99% of use cases. For the exceptional ones, as mentioned before, the extension API supports structs already (which you can see here in this excerpt of the extension api dump):
{
"name": "PhysicsServer2DExtensionRayResult",
"format": "Vector2 position;Vector2 normal;RID rid;ObjectID collider_id;Object *collider;int shape"
},
{
"name": "PhysicsServer2DExtensionShapeRestInfo",
"format": "Vector2 point;Vector2 normal;RID rid;ObjectID collider_id;int shape;Vector2 linear_velocity"
},
{
"name": "PhysicsServer2DExtensionShapeResult",
"format": "RID rid;ObjectID collider_id;Object *collider;int shape"
},
{
"name": "PhysicsServer3DExtensionMotionCollision",
"format": "Vector3 position;Vector3 normal;Vector3 collider_velocity;Vector3 collider_angular_velocity;real_t depth;int local_shape;ObjectID collider_id;RID collider;int collider_shape"
},
{
"name": "PhysicsServer3DExtensionMotionResult",
"format": "Vector3 travel;Vector3 remainder;real_t collision_depth;real_t collision_safe_fraction;real_t collision_unsafe_fraction;PhysicsServer3DExtensionMotionCollision collisions[32];int collision_count"
},
So, ultimately, I believe that the conclusion that "Godot is slow by design" is a bit rushed. What is currently missing is the move of the C# language to the GDExtension system in order to be able to take advantage of these. This is currently a work in progress.
I hope that this short article is used to dispell a few misconceptions that unintentionally arised from Sam's excellent article:
- Godot C# API is inefficient: This is absolutely not the case, but very few pathological cases remain to be solved and were already being in discussion before last week. In practice, very very few games may run into them and, by next year, hopefully none.
- Godot API is designed around GDScript: This is also not true. In fact, until Godot 4.1, typed GDScript did calls via "ptrcall" syntax, and the argument encoding was a bottleneck. As a result, we created a special path for GDScript to call more efficiently.
Thanks for reading and remember that Godot is not commercial software developed behind closed doors. All of us who make it are available online in the same communities as you. If you have any doubt, feel free to ask us directly.
Bonus: As a side note, and contrary to popular belief, the Godot data model was not created for GDScript. Originaly, the engine used other languages such as Lua or Squirrel, with several published games while an in-house engine. GDScript was developed afterwards.
I want to say that I have looked at Godot's source code, and their code is very based on simplicity and complexity where performance is needed. There's nothing wrong with it as it has the advantage of gaining more performance than Unity could ever give you. There's not a lot of things that are slowing it down. I would say the engine in terms of quality is getting on par with Unity 2022 and if these issues are fixed could even give x2 the performance gain that unity has.
@reduz Juan I am surprised at the amount of work that you've put in with the little amount of resources that the team had; this shows me that this is a passion project foremost. You're coding philosophy has nothing wrong because it pushes us to fix simple problems without getting into the core of the engine and complicating things while fixing complex problems by going back to the drawing board to make things even better.