GDExtension: Valid Ways to Expose Nodes or Node Arrays in Inspector?

Godot Version

4.2.1.stable

Question

Hello, I’m using GDExtension to develop some Node subclasses, and I want to expose various properties to be set and serialized in the Editor’s Inspector.

For most types (bool, int, float, String, etc), this is pretty straightforward: you create the variable, define getter/setter, and add the property in _bind_methods(). All good. Even exposing Resources and Objects is fairly straightforward.

However, I’m having trouble finding the correct way to expose Scene Node hookups the Inspector. I’ve tried a few different approaches - some appear to work, some don’t, and I’m unsure if I’m doing it “right.”

For example, let’s say I want to expose a Node3D property in the Inspector. Here are a few ways I’ve tried:

Using Node Pointer

The most natural approach I think is to use a Node3D* directly. So I did this:

// In header:
Node3D* TestNode3D = nullptr;
Node3D* GetTestNode3D() const { return TestNode3D; }
void SetTestNode3D(Node3D* val) { TestNode3D = val; }

// In _bind_methods():
ClassDB::bind_method(D_METHOD("GetTestNode3D"), &GDExample::GetTestNode3D); 
ClassDB::bind_method(D_METHOD("SetTestNode3D", "val"), &GDExample::SetTestNode3D); 
ClassDB::add_property("GDExample", PropertyInfo(Variant::OBJECT, "TestNode3D", PROPERTY_HINT_NODE_TYPE, "Node3D"), "SetTestNode3D", "GetTestNode3D");

This properly exposes the the Node3D property in the inspector, and only allows assigning a Node3D child node to it.

At runtime, this appears to work correctly too. Inside the scene asset, it appears to serialize as a NodePath:

TestNode3D = NodePath("Node3D")

In my mind, this is the ideal way to serialize and use a Node because it is typed and you don’t need to retrieve the node from a NodePath before using it. Though it serializes as a NodePath, it seems like the system automatically converts it to a pointer on deserialize, which is great. Is there any reason to NOT use this approach over the NodePath approach (see below)?

Using NodePath

The other way to do this appears to be with NodePath. It seems to behave the same way, but introduces some additional code.

// In header:
NodePath TestNodePath = "";
NodePath GetTestNodePath() const { return TestNodePath; }
void SetTestNodePath(NodePath val) { TestNodePath = val; }

// In _bind_methods():
ClassDB::bind_method(D_METHOD("GetTestNodePath"), &GDExample::GetTestNodePath);
ClassDB::bind_method(D_METHOD("SetTestNodePath", "val"), &GDExample::SetTestNodePath);
ClassDB::add_property("GDExample", PropertyInfo(Variant::NODE_PATH, "TestNodePath", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"), "SetTestNodePath", "GetTestNodePath");

This properly exposes a Node3D property in the inspector (it’s identical to the naked pointer approach). It also serializes the same way.

At runtime, this works, but you need to first call get_node_or_null and cast to the right type, which is tedious.

This seems to be the way I’ve seen the engine source code serialize nodes, but I wonder if it’s OK to use the naked pointer approach too?

Bringing Arrays Into the Mix

To add a bit more complexity, I also had a need to expose a list of Node3D hookups in the Inspector. Here’s where I started to see some strange behavior, and I’m unsure if I’m missing something, or this is working as intended.

At first I thought “ok, serializing Node* works, so I can use that for a list.” I tried this:

// In header:
TypedArray<Node3D> SpawnPoints = {};
TypedArray<Node3D> GetSpawnPoints() const { return SpawnPoints; } 
void SetSpawnPoints(TypedArray<Node3D> val) { SpawnPoints = val; }

// In _bind_methods():
ClassDB::bind_method(D_METHOD("GetSpawnPoints"), &TeamBase::GetSpawnPoints); 
ClassDB::bind_method(D_METHOD("SetSpawnPoints", "val"), &TeamBase::SetSpawnPoints); 
ClassDB::add_property("TeamBase", PropertyInfo(Variant::ARRAY, "SpawnPoints", PROPERTY_HINT_ARRAY_TYPE, vformat("%s/%s:%s", Variant::OBJECT, PROPERTY_HINT_NODE_TYPE, "Node3D")), "SetSpawnPoints", "GetSpawnPoints");

This method does properly show a TypedArray in the Inspector, and only allows assigning Node3D. Looks promising and correct!

However, when serialized to file, it looks incorrect. It appears to be creating a copy of the Node3D in the TypedArray. At runtime, accessing the node also doesn’t give correct results, and the node shows as “Orphaned”. My guess is that the system is creating a copy of the Node3D instead of pointing to the one assigned from the Scene.

I tried switching the above to TypedArray<Node3D*>, but that doesn’t compile. I’m guessing that Node3D* doesn’t convert to Variant or something along those lines.

If I switch the above to TypedArray<NodePath>, everything works correctly, but obviously we then have the annoyance of no longer dealing with typed pointers and having to first convert the NodePath to a typed pointer before we can make use of the node.

Conclusion

Anyway, I know that’s a lot, but if any GDExtension experts have guidance on the correct way to expose Node or Node Arrays in the Inspector, or any pitfalls related to that, it would be much appreciated!

As mentioned above, using pointers is preferred simply because it maintains type info and doesn’t have the extra code to get the node and convert it. But I’m unclear whether there is any way to do that when an Array or TypedArray is involved. And I’m not sure if there are any downsides to the pointer approach that I’m missing for single objects.

1 Like

Well, I found at least one downside to using direct pointers over NodePath.

It appears that when you duplicate a node (either using the in-editor Ctrl+D, or via script with the “duplicate” function), using a direct pointer causes the duplicated fields to point to the original instance, NOT the duplicated instance.

On the other hand, if you “duplicate” an object by instantiating its scene multiple times, you get the behavior you expect (each instance’s node pointers point to that instance’s children).

This seems inconsistent and not very intuitive, so I’m inclined to view it as a bug, or at least behavior that might be considered incorrect.