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.

2 Likes

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.

Also encountered this problem while trying to export TypedArray of custom nodes.
Really hoping there would be at least small documentation on how add properties for most common types using GDExtension like Nodes, Resources, TypedArrays etc.

I Managed to get this work using PROPERTY_HINT_TYPE_STRING

String arrayType = vformat("%s/%s:%s", Variant::OBJECT, PROPERTY_HINT_NODE_TYPE, "PredicateProvider");
ADD_PROPERTY(PropertyInfo(Variant::ARRAY, "predicates", PROPERTY_HINT_TYPE_STRING, arrayType, PROPERTY_HINT_ARRAY_TYPE),
    "set_predicates", "get_predicates");

Do note that if any of the elements in the array get deleted in runtime this will cause exception when trying to access that TypedArray element. To avoid this you’ll need logic that allows you to remove these nodes e.g when the node being deleted receives exit_tree signal.


In this chase I would advice you to use the get_children() instead to populate your collection of spawn points. Meaning that you should make parent node and make the spawn point nodes children of that node. This removes the need to drag and drop each spawn point to TypedArray property.

Another solution is to make registry for spawn points and make each spawn point register itself to the registry on startup and deregister itself on exit_tree. Alternatively the registry could search the scene for nodes of specific type and use that to populate itself instead. This allows a bit more flexibility in terms of Node hierarchy but may lead to some execution order challenges.

Few snippets:

Here’s few snippets from my code where I populate godot::Vector action_sequence variable. The action_sequence is of type Vector<LogicAction*> and the populate_action_sequence_from_children gets called when NOTIFICATION_READY is received.

Note: I am still fairly inexperienced with C/C++ and Godot code-base so there may be better/cleaner ways to do this.

// logic_player.cpp
void LogicPlayer::populate_action_sequence_from_children()
{
    for (LogicAction * action : action_sequence)
    {
        disconnect_from_logic_action(action);
    }

    action_sequence.clear();
    TypedArray<Node> childNodes = get_children();

    int size = childNodes.size();
    for (int i = 0; i < size; i++)
    {
        LogicAction *current = Object::cast_to<LogicAction>(childNodes[i]);
        if (nullptr != current)
        {
            action_sequence.push_back(current);
            // We are using custom exit_tree signal to avoid segfault if node gets removed. 
            connect_to_logic_action(current);
        }
    }
}

void LogicPlayer::remove_action(LogicAction *action)
{
    if (nullptr == action)
    {
        return;
    }

    int action_index = action_sequence.find(action);
    if (action_index >= 0)
    {
        log((String)get_name() + ": Removing action with index " + String::num_int64(action_index) + " name: " + action->get_name());
        disconnect_from_logic_action(action_sequence[action_index]);
        action_sequence.remove_at(action_index);
    }
    else
    {
        log((String)get_name() + ": Unable to remove action. Given action was not found in action sequence");
    }
}

void LogicPlayer::connect_to_logic_action(LogicAction* logic_action)
{
    if (is_playmode() && nullptr != logic_action)
    {
        logic_action->connect(logic_action->SIGNAL_NAME_ACTION_EXIT_TREE, Callable(this, "remove_action"));
    }
}

void LogicPlayer::disconnect_from_logic_action(LogicAction* logic_action)
{
    if (is_playmode() && nullptr != logic_action)
    {
        logic_action->disconnect(logic_action->SIGNAL_NAME_ACTION_EXIT_TREE, Callable(this, "remove_action"));
    }
}
# logic_action.cpp
void LogicAction::_notification(int p_what)
{
    if (is_playmode())
    {
        switch (p_what)
        {
        case NOTIFICATION_EXIT_TREE:
        {
            send_exit_tree_signal();
            exit_tree();
            break;
        }
        default:
            break;
        }
    }
}

// Built in Node exit_tree signal lacks the information about which node was removed. 
// This is needed to know which node entry from collection should be removed. 
void LogicAction::send_exit_tree_signal()
{
    Error error = emit_signal(SIGNAL_NAME_ACTION_EXIT_TREE, this);
    if (is_signal_error(error))
    {
        logErr("Signal " + SIGNAL_NAME_ACTION_EXIT_TREE + " returned error: " + String::num_int64(error));
    }
}